feat: new management api crud endpoint for responses (#4716)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: victorvhs017 <115753265+victorvhs017@users.noreply.github.com>
This commit is contained in:
Piyush Gupta
2025-03-06 22:46:06 +05:30
committed by GitHub
parent 4113dd1873
commit 140aee749b
129 changed files with 9360 additions and 284 deletions

View File

@@ -189,6 +189,9 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1

View File

@@ -84,7 +84,7 @@ jobs:
- name: Run App
run: |
NODE_ENV=test pnpm start --filter=@formbricks/web &
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do
if [ $(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/health) -eq 200 ]; then
@@ -136,3 +136,13 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
if: failure()
with:
name: app-logs
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log

View File

@@ -6,6 +6,8 @@
"dbaeumer.vscode-eslint", // eslint plugin
"esbenp.prettier-vscode", // prettier plugin
"Prisma.prisma", // syntax|format|completion for prisma
"yzhang.markdown-all-in-one" // nicer markdown support
"yzhang.markdown-all-in-one", // nicer markdown support
"vitest.explorer", // run tests directly from the code window
"sonarsource.sonarlint-vscode" // sonarqube linter for vscode
]
}

View File

@@ -1,8 +1,8 @@
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { hasOrganizationAccess } from "@formbricks/lib/auth";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -16,7 +16,7 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
// check auth
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const hasAccess = await hasOrganizationAccess(session.user, organizationId);
const hasAccess = await hasOrganizationAccess(session.user.id, organizationId);
if (!hasAccess) throw new AuthorizationError("Unauthorized");
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organizationId);

View File

@@ -1,7 +1,7 @@
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { hasOrganizationAccess } from "@formbricks/lib/auth";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getProject } from "@formbricks/lib/project/service";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
@@ -15,7 +15,7 @@ export const GET = async (_: Request, context: { params: Promise<{ projectId: st
if (!session) throw new AuthenticationError("Not authenticated");
const project = await getProject(projectId);
if (!project) return notFound();
const hasAccess = await hasOrganizationAccess(session.user, project.organizationId);
const hasAccess = await hasOrganizationAccess(session.user.id, project.organizationId);
if (!hasAccess) throw new AuthorizationError("Unauthorized");
// redirect to project's production environment
const environments = await getEnvironments(project.id);

View File

@@ -1,16 +1,19 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentIdFromApiKey } from "./lib/api-key";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (environmentId) {
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId,
hashedApiKey,
};
return authentication;
}

View File

@@ -6,8 +6,7 @@ import { cache } from "@formbricks/lib/cache";
import { getHash } from "@formbricks/lib/crypto";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
const hashedKey = getHash(apiKey);
@@ -42,7 +41,7 @@ export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Pro
throw error;
}
},
[`getEnvironmentIdFromApiKey-${apiKey}`],
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}

View File

@@ -0,0 +1,15 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
// check for session (browser usage)
let session: Session | null;
if (req && res) {
session = await getServerSession(req, res, authOptions);
} else {
session = await getServerSession(authOptions);
}
if (session && "user" in session) return session.user;
};

View File

@@ -1,4 +1,5 @@
import { getSessionUser, hashApiKey } from "@/app/lib/api/apiHelper";
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,3 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/displays/route";
export { OPTIONS, POST };

View File

@@ -0,0 +1,3 @@
import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route";
export { OPTIONS, GET };

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -0,0 +1,3 @@
import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,42 @@
import { contactCache } from "@/lib/cache/contact";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
export const getContact = reactCache((contactId: string) =>
cache(
async () => {
const contact = await prisma.contact.findUnique({
where: { id: contactId },
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
if (!contact) {
return null;
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
return {
id: contact.id,
attributes: contactAttributes,
};
},
[`getContact-responses-api-${contactId}`],
{
tags: [contactCache.tag.byId(contactId)],
}
)()
);

View File

@@ -0,0 +1,145 @@
import "server-only";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@formbricks/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { responseCache } from "@formbricks/lib/response/cache";
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { getContact } from "./contact";
export const createResponse = async (responseInput: TResponseInputV2): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const {
environmentId,
language,
contactId,
surveyId,
displayId,
finished,
data,
meta,
singleUseId,
variables,
ttc: initialTtc,
createdAt,
updatedAt,
} = responseInput;
try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
let userId: string | undefined = undefined;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", environmentId);
}
if (contactId) {
contact = await getContact(contactId);
userId = contact?.attributes.userId;
}
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData: Prisma.ResponseCreateInput = {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
data: data,
language: language,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
const responsePrisma = await prisma.response.create({
data: prismaData,
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),
};
responseCache.revalidate({
environmentId,
id: response.id,
contactId: contact?.id,
...(singleUseId && { singleUseId }),
userId,
surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
if (IS_FORMBRICKS_CLOUD) {
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
const responsesLimit = organization.billing.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
}
}
}
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,138 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
import { getSurvey } from "@formbricks/lib/survey/service";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { createResponse } from "./lib/response";
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
}
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.contactId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
let response: TResponse;
try {
const meta: TResponseInputV2["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
return responses.successResponse({ id: response.id }, true);
};

View File

@@ -0,0 +1,6 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseInput } from "@formbricks/types/responses";
export const ZResponseInputV2 = ZResponseInput.omit({ userId: true }).extend({ contactId: ZId.nullish() });
export type TResponseInputV2 = z.infer<typeof ZResponseInputV2>;

View File

@@ -0,0 +1,3 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
export { OPTIONS, POST };

View File

@@ -0,0 +1,3 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route";
export { OPTIONS, POST };

View File

@@ -0,0 +1,3 @@
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
export { POST, OPTIONS };

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route";
export { GET, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/management/responses/route";
export { GET, POST };

View File

@@ -1,75 +0,0 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
export const hasEnvironmentAccess = async (
req: NextApiRequest,
res: NextApiResponse,
environmentId: string
) => {
if (req.headers["x-api-key"]) {
const ownership = await hasApiEnvironmentAccess(req.headers["x-api-key"].toString(), environmentId);
if (!ownership) {
return false;
}
} else {
const user = await getSessionUser(req, res);
if (!user) {
return false;
}
const ownership = await hasUserEnvironmentAccess(user.id, environmentId);
if (!ownership) {
return false;
}
}
return true;
};
export const hasApiEnvironmentAccess = async (apiKey, environmentId) => {
// write function to check if the API Key has access to the environment
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
},
select: {
environmentId: true,
},
});
if (apiKeyData?.environmentId === environmentId) {
return true;
}
return false;
};
export const hasOrganizationAccess = async (user, organizationId) => {
const membership = await prisma.membership.findUnique({
where: {
userId_organizationId: {
userId: user.id,
organizationId: organizationId,
},
},
});
if (membership) {
return true;
}
return false;
};
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
// check for session (browser usage)
let session: Session | null;
if (req && res) {
session = await getServerSession(req, res, authOptions);
} else {
session = await getServerSession(authOptions);
}
if (session && "user" in session) return session.user;
};

View File

@@ -15,7 +15,8 @@ interface ApiErrorResponse {
| "unauthorized"
| "method_not_allowed"
| "not_authenticated"
| "forbidden";
| "forbidden"
| "too_many_requests";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -247,7 +248,7 @@ const tooManyRequestsResponse = (
return Response.json(
{
code: "internal_server_error",
code: "too_many_requests",
message,
details: {},
} as ApiErrorResponse,

View File

@@ -14,6 +14,11 @@ export const isClientSideApiRoute = (url: string): boolean => {
return regex.test(url);
};
export const isManagementApiRoute = (url: string): boolean => {
const regex = /^\/api\/v\d+\/management\//;
return regex.test(url);
};
export const isShareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
return regex.test(url);

View File

@@ -12,22 +12,36 @@ import {
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { logApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ipAddress } from "@vercel/functions";
import { getToken } from "next-auth/jwt";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
export const middleware = async (request: NextRequest) => {
// issue with next auth types; let's review when new fixes are available
const token = await getToken({ req: request as any });
const enforceHttps = (request: NextRequest): Response | null => {
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
const apiError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 403 });
}
return null;
};
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
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);
@@ -35,13 +49,62 @@ export const middleware = async (request: NextRequest) => {
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) {
return NextResponse.json({ error: "Invalid callback URL" });
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (token && callbackUrl) {
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
}
if (process.env.NODE_ENV !== "production" || RATE_LIMITING_DISABLED) {
return NextResponse.next();
return null;
};
const applyRateLimiting = (request: NextRequest, ip: string) => {
if (isLoginRoute(request.nextUrl.pathname)) {
loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
shareUrlLimiter(`share-${ip}`);
}
};
export const middleware = async (originalRequest: NextRequest) => {
// Create a new Request object to override headers and add a unique request ID header
const request = new NextRequest(originalRequest, {
headers: new Headers(originalRequest.headers),
});
request.headers.set("x-request-id", uuidv4());
// Create a new NextResponse object to forward the new request with headers
const nextResponseWithCustomHeader = NextResponse.next({
request: {
headers: request.headers,
},
});
// Enforce HTTPS for management endpoints
if (isManagementApiRoute(request.nextUrl.pathname)) {
const httpsResponse = enforceHttps(request);
if (httpsResponse) return httpsResponse;
}
// Handle authentication
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) {
return nextResponseWithCustomHeader;
}
let ip =
@@ -51,32 +114,19 @@ export const middleware = async (request: NextRequest) => {
if (ip) {
try {
if (isLoginRoute(request.nextUrl.pathname)) {
await loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
await signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
await verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
await forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
await shareUrlLimiter(`share-${ip}`);
}
return NextResponse.next();
applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
console.log(`Rate Limiting IP: ${ip}`);
return NextResponse.json({ error: "Too many requests, Please try after a while!" }, { status: 429 });
const apiError: ApiErrorResponseV2 = {
type: "too_many_requests",
details: [{ field: "", issue: "Too many requests. Please try again later." }],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 429 });
}
}
return NextResponse.next();
return nextResponseWithCustomHeader;
};
export const config = {
@@ -94,5 +144,7 @@ export const config = {
"/api/packages/:path*",
"/auth/verification-requested",
"/auth/forgot-password",
"/api/v1/management/:path*",
"/api/v2/management/:path*",
],
};

View File

@@ -0,0 +1,70 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@formbricks/lib/constants";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export type RateLimitHelper = {
identifier: string;
opts?: LimitOptions;
/**
* Using a callback instead of a regular return to provide headers even
* when the rate limit is reached and an error is thrown.
**/
onRateLimiterResponse?: (response: RatelimitResponse) => void;
};
let warningDisplayed = false;
/** Prevent flooding the logs while testing/building */
function logOnce(message: string) {
if (warningDisplayed) return;
console.warn(message);
warningDisplayed = true;
}
export function rateLimiter() {
if (RATE_LIMITING_DISABLED) {
logOnce("Rate limiting disabled");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
if (!UNKEY_ROOT_KEY) {
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
const timeout = {
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
ms: 5000,
};
const limiter = {
api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
namespace: "api",
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
timeout,
}),
};
async function rateLimit({ identifier, opts }: RateLimitHelper) {
return await limiter.api.limit(identifier, opts);
}
return rateLimit;
}
export const checkRateLimitAndThrowError = async ({
identifier,
opts,
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
const response = await rateLimiter()({ identifier, opts });
const { success } = response;
if (!success) {
return err({
type: "too_many_requests",
});
}
return okVoid();
};

View File

@@ -0,0 +1,270 @@
import { ApiErrorDetails, ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiSuccessResponse } from "@/modules/api/v2/types/api-success";
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponseV2;
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
const badRequestResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 400,
message: "Bad Request",
details,
},
},
{
status: 400,
headers,
}
);
};
const unauthorizedResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 401,
message: "Unauthorized",
},
},
{
status: 401,
headers,
}
);
};
const forbiddenResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 403,
message: "Forbidden",
},
},
{
status: 403,
headers,
}
);
};
const notFoundResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 404,
message: "Not Found",
details,
},
},
{
status: 404,
headers,
}
);
};
const conflictResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 409,
message: "Conflict",
},
},
{
status: 409,
headers,
}
);
};
const unprocessableEntityResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details: ApiErrorDetails;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 422,
message: "Unprocessable Entity",
details,
},
},
{
status: 422,
headers,
}
);
};
const tooManyRequestsResponse = ({
cors = false,
cache = "private, no-store",
}: {
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 429,
message: "Too Many Requests",
},
},
{
status: 429,
headers,
}
);
};
const internalServerErrorResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 500,
message: "Internal Server Error",
details,
},
},
{
status: 500,
headers,
}
);
};
const successResponse = ({
data,
meta,
cors = false,
cache = "private, no-store",
}: {
data: Object;
meta?: Record<string, unknown>;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
data,
meta,
} as ApiSuccessResponse,
{
status: 200,
headers,
}
);
};
export const responses = {
badRequestResponse,
unauthorizedResponse,
forbiddenResponse,
notFoundResponse,
conflictResponse,
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
successResponse,
};

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@unkey/ratelimit", () => ({
Ratelimit: vi.fn(),
}));
describe("when rate limiting is disabled", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@formbricks/lib/constants");
vi.doMock("@formbricks/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: true,
}));
});
test("should log a warning once and return a stubbed response", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const res1 = await rateLimiter()({ identifier: "test-id" });
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(warnSpy).toHaveBeenCalledWith("Rate limiting disabled");
// Subsequent calls won't log again.
await rateLimiter()({ identifier: "another-id" });
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
});
describe("when UNKEY_ROOT_KEY is missing", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@formbricks/lib/constants");
vi.doMock("@formbricks/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "",
}));
});
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "test-id" });
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(warnSpy).toHaveBeenCalledWith("Disabled due to not finding UNKEY_ROOT_KEY env variable");
warnSpy.mockRestore();
});
});
describe("when rate limiting is active (enabled)", () => {
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
let limitMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@formbricks/lib/constants");
vi.doMock("@formbricks/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "valid-key",
}));
limitMock = vi.fn().mockResolvedValue(mockResponse);
const RatelimitMock = vi.fn().mockImplementation(() => {
return { limit: limitMock };
});
vi.doMock("@unkey/ratelimit", () => ({
Ratelimit: RatelimitMock,
}));
});
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
const { rateLimiter } = await import("../rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
expect(res).toEqual(mockResponse);
});
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(true);
});
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "too_many_requests" });
}
});
});

View File

@@ -0,0 +1,183 @@
import { describe, expect, test } from "vitest";
import { responses } from "../response";
describe("API Responses", () => {
describe("badRequestResponse", () => {
test("return a 400 response with error details", async () => {
const details = [{ field: "param", issue: "invalid" }];
const res = responses.badRequestResponse({ details });
expect(res.status).toBe(400);
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
const body = await res.json();
expect(body).toEqual({
error: {
code: 400,
message: "Bad Request",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.badRequestResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("unauthorizedResponse", () => {
test("return a 401 response with the proper error message", async () => {
const res = responses.unauthorizedResponse();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({
error: {
code: 401,
message: "Unauthorized",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.unauthorizedResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("forbiddenResponse", () => {
test("return a 403 response", async () => {
const res = responses.forbiddenResponse();
expect(res.status).toBe(403);
const body = await res.json();
expect(body).toEqual({
error: {
code: 403,
message: "Forbidden",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.forbiddenResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("notFoundResponse", () => {
test("return a 404 response with error details", async () => {
const details = [{ field: "resource", issue: "not found" }];
const res = responses.notFoundResponse({ details });
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({
error: {
code: 404,
message: "Not Found",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.notFoundResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("conflictResponse", () => {
test("return a 409 response", async () => {
const res = responses.conflictResponse();
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toEqual({
error: {
code: 409,
message: "Conflict",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.conflictResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("unprocessableEntityResponse", () => {
test("return a 422 response with error details", async () => {
const details = [{ field: "data", issue: "malformed" }];
const res = responses.unprocessableEntityResponse({ details });
expect(res.status).toBe(422);
const body = await res.json();
expect(body).toEqual({
error: {
code: 422,
message: "Unprocessable Entity",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.unprocessableEntityResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("tooManyRequestsResponse", () => {
test("return a 429 response", async () => {
const res = responses.tooManyRequestsResponse();
expect(res.status).toBe(429);
const body = await res.json();
expect(body).toEqual({
error: {
code: 429,
message: "Too Many Requests",
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.tooManyRequestsResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("internalServerErrorResponse", () => {
test("return a 500 response with error details", async () => {
const details = [{ field: "server", issue: "crashed" }];
const res = responses.internalServerErrorResponse({ details });
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({
error: {
code: 500,
message: "Internal Server Error",
details,
},
});
});
test("include CORS headers when cors is true", () => {
const res = responses.internalServerErrorResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("successResponse", () => {
test("return a success response with the provided data", async () => {
const data = { foo: "bar" };
const meta = { page: 1 };
const res = responses.successResponse({ data, meta });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual(data);
expect(body.meta).toEqual(meta);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.successResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
});

View File

@@ -0,0 +1,201 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
// Add the request id header
mockRequest.headers.set("x-request-id", "123");
describe("utils", () => {
describe("handleApiError", () => {
test('return bad request response for "bad_request" error', async () => {
const details = [{ field: "param", issue: "invalid" }];
const error: ApiErrorResponseV2 = { type: "bad_request", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error.code).toBe(400);
expect(body.error.message).toBe("Bad Request");
expect(body.error.details).toEqual(details);
});
test('return unauthorized response for "unauthorized" error', async () => {
const error: ApiErrorResponseV2 = { type: "unauthorized" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error.code).toBe(401);
expect(body.error.message).toBe("Unauthorized");
});
test('return forbidden response for "forbidden" error', async () => {
const error: ApiErrorResponseV2 = { type: "forbidden" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error.code).toBe(403);
expect(body.error.message).toBe("Forbidden");
});
test('return not found response for "not_found" error', async () => {
const details = [{ field: "resource", issue: "not found" }];
const error: ApiErrorResponseV2 = { type: "not_found", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error.code).toBe(404);
expect(body.error.message).toBe("Not Found");
expect(body.error.details).toEqual(details);
});
test('return conflict response for "conflict" error', async () => {
const error: ApiErrorResponseV2 = { type: "conflict" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(409);
const body = await response.json();
expect(body.error.code).toBe(409);
expect(body.error.message).toBe("Conflict");
});
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
const details = [{ field: "data", issue: "malformed" }];
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(422);
const body = await response.json();
expect(body.error.code).toBe(422);
expect(body.error.message).toBe("Unprocessable Entity");
expect(body.error.details).toEqual(details);
});
test('return too many requests response for "too_many_requests" error', async () => {
const error: ApiErrorResponseV2 = { type: "too_many_requests" };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(429);
const body = await response.json();
expect(body.error.code).toBe(429);
expect(body.error.message).toBe("Too Many Requests");
});
test('return internal server error response for "internal_server_error" error with default message', async () => {
const details = [{ field: "server", issue: "error occurred" }];
const error: ApiErrorResponseV2 = { type: "internal_server_error", details };
const response = handleApiError(mockRequest, error);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error.code).toBe(500);
expect(body.error.message).toBe("Internal Server Error");
expect(body.error.details).toEqual([
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
]);
});
});
describe("formatZodError", () => {
test("correctly format a Zod error", () => {
const zodError = {
issues: [
{
path: ["field1"],
message: "Invalid value for field1",
},
{
path: ["field2", "subfield"],
message: "Field2 subfield is required",
},
],
} as ZodError;
const formatted = formatZodError(zodError);
expect(formatted).toEqual([
{ field: "field1", issue: "Invalid value for field1" },
{ field: "field2.subfield", issue: "Field2 subfield is required" },
]);
});
test("return an empty array if there are no issues", () => {
const zodError = { issues: [] } as unknown as ZodError;
const formatted = formatZodError(zodError);
expect(formatted).toEqual([]);
});
});
describe("logApiRequest", () => {
test("logs API request details", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc&safeParam=value");
mockRequest.headers.set("x-request-id", "123");
logApiRequest(mockRequest, 200, 100);
expect(consoleLogSpy).toHaveBeenCalledWith(
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n correlationId: 123\n queryParams: {"safeParam":"value"}`
);
consoleLogSpy.mockRestore();
});
test("logs API request details without correlationId and without safe query params", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc");
mockRequest.headers.delete("x-request-id");
logApiRequest(mockRequest, 200, 100);
expect(consoleLogSpy).toHaveBeenCalledWith(
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n queryParams: {}`
);
consoleLogSpy.mockRestore();
});
});
describe("logApiError", () => {
test("logs API error details", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
logApiError(mockRequest, error);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`[API ERROR DETAILS]\n correlationId: 123\n error: ${JSON.stringify(error, null, 2)}`
);
consoleErrorSpy.mockRestore();
});
test("logs API error details without correlationId", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.delete("x-request-id");
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
logApiError(mockRequest, error);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`[API ERROR DETAILS]\n error: ${JSON.stringify(error, null, 2)}`
);
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,65 @@
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ZodError } from "zod";
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
logApiError(request, err);
switch (err.type) {
case "bad_request":
return responses.badRequestResponse({ details: err.details });
case "unauthorized":
return responses.unauthorizedResponse();
case "forbidden":
return responses.forbiddenResponse();
case "not_found":
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse();
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":
return responses.tooManyRequestsResponse();
default:
// Replace with a generic error message, because we don't want to expose internal errors to API users.
return responses.internalServerErrorResponse({
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
});
}
};
export const formatZodError = (error: ZodError) => {
return error.issues.map((issue) => ({
field: issue.path.join("."),
issue: issue.message,
}));
};
export const logApiRequest = (request: Request, responseStatus: number, duration: number): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
const correlationId = request.headers.get("x-request-id") || "";
const queryParams = Object.fromEntries(url.searchParams.entries());
const sensitiveParams = ["apikey", "token", "secret"];
const safeQueryParams = Object.fromEntries(
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
);
console.log(
`[API REQUEST DETAILS] ${method} ${path} - ${responseStatus} - ${duration}ms${correlationId ? `\n correlationId: ${correlationId}` : ""}\n queryParams: ${JSON.stringify(safeQueryParams)}`
);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") || "";
console.error(
`[API ERROR DETAILS]${correlationId ? `\n correlationId: ${correlationId}` : ""}\n error: ${JSON.stringify(error, null, 2)}`
);
};

View File

@@ -0,0 +1,106 @@
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { ZodRawShape, z } from "zod";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { err } from "@formbricks/types/error-handlers";
import { authenticateRequest } from "./authenticate-request";
export type HandlerFn<TInput = Record<string, unknown>> = ({
authentication,
parsedInput,
request,
}: {
authentication: TAuthenticationApiKey;
parsedInput: TInput;
request: Request;
}) => Promise<Response>;
export type ExtendedSchemas = {
body?: z.ZodObject<ZodRawShape>;
query?: z.ZodObject<ZodRawShape>;
params?: z.ZodObject<ZodRawShape>;
};
// Define a type that returns separate keys for each input type.
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
};
export const apiWrapper = async <S extends ExtendedSchemas>({
request,
schemas,
externalParams,
rateLimit = true,
handler,
}: {
request: Request;
schemas?: S;
externalParams?: Promise<Record<string, any>>;
rateLimit?: boolean;
handler: HandlerFn<ParsedSchemas<S>>;
}): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication.ok) throw authentication.error;
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
const bodyData = await request.json();
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
throw err({
type: "forbidden",
details: formatZodError(bodyResult.error),
});
}
parsedInput.body = bodyResult.data as ParsedSchemas<S>["body"];
}
if (schemas?.query) {
const url = new URL(request.url);
const queryObject = Object.fromEntries(url.searchParams.entries());
const queryResult = schemas.query.safeParse(queryObject);
if (!queryResult.success) {
throw err({
type: "unprocessable_entity",
details: formatZodError(queryResult.error),
});
}
parsedInput.query = queryResult.data as ParsedSchemas<S>["query"];
}
if (schemas?.params) {
const paramsObject = (await externalParams) || {};
console.log("paramsObject: ", paramsObject);
const paramsResult = schemas.params.safeParse(paramsObject);
if (!paramsResult.success) {
throw err({
type: "unprocessable_entity",
details: formatZodError(paramsResult.error),
});
}
parsedInput.params = paramsResult.data as ParsedSchemas<S>["params"];
}
if (rateLimit) {
const rateLimitResponse = await checkRateLimitAndThrowError({
identifier: authentication.data.hashedApiKey,
});
if (!rateLimitResponse.ok) {
throw rateLimitResponse.error;
}
}
return handler({
authentication: authentication.data,
parsedInput,
request,
});
} catch (err) {
return handleApiError(request, err);
}
};

View File

@@ -0,0 +1,33 @@
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const authenticateRequest = async (
request: Request
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentIdResult.ok) {
return err(environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
const hashedApiKey = hashApiKey(apiKey);
if (environmentId) {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId,
hashedApiKey,
};
return ok(authentication);
}
return err({
type: "forbidden",
});
}
return err({
type: "unauthorized",
});
};

View File

@@ -0,0 +1,32 @@
import { logApiRequest } from "@/modules/api/v2/lib/utils";
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
request,
schemas,
externalParams,
rateLimit = true,
handler,
}: {
request: Request;
schemas?: S;
externalParams?: Promise<Record<string, any>>;
rateLimit?: boolean;
handler: HandlerFn<ParsedSchemas<S>>;
}): Promise<Response> => {
const startTime = Date.now();
const response = await apiWrapper({
request,
schemas,
externalParams,
rateLimit,
handler,
});
const duration = Date.now() - startTime;
logApiRequest(request, response.status, duration);
return response;
};

View File

@@ -0,0 +1,18 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export const checkAuthorization = ({
authentication,
environmentId,
}: {
authentication: TAuthenticationApiKey;
environmentId: string;
}): Result<void, ApiErrorResponseV2> => {
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
return err({
type: "unauthorized",
});
}
return okVoid();
};

View File

@@ -0,0 +1,300 @@
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
vi.mock("../authenticate-request", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
checkRateLimitAndThrowError: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
}));
describe("apiWrapper", () => {
it("should handle request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
});
it("should handle errors and return error response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(err({ type: "unauthorized" }));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 401 }));
const handler = vi.fn();
const response = await apiWrapper({
request,
handler,
});
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
});
it("should parse body schema correctly", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ key: "value" }),
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
parsedInput: { body: { key: "value" } },
})
);
});
it("should handle body schema errors", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ key: 123 }),
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
const querySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
schemas: { query: querySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
parsedInput: { query: { key: "value" } },
})
);
});
it("should handle query schema errors", async () => {
const request = new Request("http://localhost?foo%ZZ=abc");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const querySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { query: querySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should parse params schema correctly", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
schemas: { params: paramsSchema },
externalParams: Promise.resolve({ key: "value" }),
rateLimit: false,
handler,
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
parsedInput: { params: { key: "value" } },
})
);
});
it("should handle no external params", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { params: paramsSchema },
externalParams: undefined,
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should handle params schema errors", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { params: paramsSchema },
externalParams: Promise.resolve({ notKey: "value" }),
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
});
it("should handle rate limit errors", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
);
vi.mocked(handleApiError).mockImplementation(
(_request: Request, _error: ApiErrorResponseV2): Response =>
new Response("rate limit exceeded", { status: 429 })
);
const handler = vi.fn();
const response = await apiWrapper({
request,
handler,
});
expect(response.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { authenticateRequest } from "../authenticate-request";
vi.mock("@/modules/api/v2/management/lib/api-key", () => ({
getEnvironmentIdFromApiKey: vi.fn(),
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("authenticateRequest", () => {
it("should return authentication data if apiKey is valid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id"));
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
const result = await authenticateRequest(request);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
});
}
});
it("should return forbidden error if environmentId is not found", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" }));
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "forbidden" });
}
});
it("should return forbidden error if environmentId is empty", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok(""));
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "forbidden" });
}
});
it("should return unauthorized error if apiKey is missing", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -0,0 +1,32 @@
import { logApiRequest } from "@/modules/api/v2/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { apiWrapper } from "../api-wrapper";
import { authenticatedApiClient } from "../authenticated-api-client";
vi.mock("../api-wrapper", () => ({
apiWrapper: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
logApiRequest: vi.fn(),
}));
describe("authenticatedApiClient", () => {
it("should log request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(apiWrapper).mockResolvedValue(new Response("ok", { status: 200 }));
vi.mocked(logApiRequest).mockReturnValue();
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await authenticatedApiClient({
request,
handler,
});
expect(response.status).toBe(200);
expect(logApiRequest).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkAuthorization } from "../check-authorization";
describe("checkAuthorization", () => {
it("should return ok if authentication is valid", () => {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
};
const result = checkAuthorization({ authentication, environmentId: "env-id" });
expect(result.ok).toBe(true);
});
it("should return unauthorized error if environmentId does not match", () => {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
};
const result = checkAuthorization({ authentication, environmentId: "different-env-id" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -0,0 +1,79 @@
import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKey",
summary: "Get a contact attribute key",
description: "Gets a contact attribute key from the database.",
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
}),
},
tags: ["Management API > Contact Attribute Keys"],
responses: {
"200": {
description: "Contact attribute key retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact attribute key deleted successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};
export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttributeKey",
summary: "Update a contact attribute key",
description: "Updates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The contact attribute key to update",
content: {
"application/json": {
schema: ZContactAttributeKeyInput,
},
},
},
responses: {
"200": {
description: "Contact attribute key updated successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};

View File

@@ -0,0 +1,65 @@
import {
deleteContactAttributeKeyEndpoint,
getContactAttributeKeyEndpoint,
updateContactAttributeKeyEndpoint,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi";
import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys",
summary: "Get contact attribute keys",
description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter,
},
responses: {
"200": {
description: "Contact attribute keys retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttributeKey),
},
},
},
},
};
export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttributeKey",
summary: "Create a contact attribute key",
description: "Creates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
requestBody: {
required: true,
description: "The contact attribute key to create",
content: {
"application/json": {
schema: ZContactAttributeKeyInput,
},
},
},
responses: {
"201": {
description: "Contact attribute key created successfully.",
},
},
};
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
"/contact-attribute-keys": {
get: getContactAttributeKeysEndpoint,
post: createContactAttributeKeyEndpoint,
},
"/contact-attribute-keys/{id}": {
get: getContactAttributeKeyEndpoint,
put: updateContactAttributeKeyEndpoint,
delete: deleteContactAttributeKeyEndpoint,
},
};

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
export const ZGetContactAttributeKeysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
type: true,
environmentId: true,
}).openapi({
ref: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeKeyInput = z.infer<typeof ZContactAttributeKeyInput>;

View File

@@ -0,0 +1,79 @@
import { ZContactAttributeInput } from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttribute",
summary: "Get a contact attribute",
description: "Gets a contact attribute from the database.",
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
tags: ["Management API > Contact Attributes"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttribute",
summary: "Delete a contact attribute",
description: "Deletes a contact attribute from the database.",
tags: ["Management API > Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttribute",
summary: "Update a contact attribute",
description: "Updates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};

View File

@@ -0,0 +1,65 @@
import {
deleteContactAttributeEndpoint,
getContactAttributeEndpoint,
updateContactAttributeEndpoint,
} from "@/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi";
import {
ZContactAttributeInput,
ZGetContactAttributesFilter,
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributes",
summary: "Get contact attributes",
description: "Gets contact attributes from the database.",
tags: ["Management API > Contact Attributes"],
requestParams: {
query: ZGetContactAttributesFilter,
},
responses: {
"200": {
description: "Contact attributes retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttribute),
},
},
},
},
};
export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttribute",
summary: "Create a contact attribute",
description: "Creates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
requestBody: {
required: true,
description: "The contact attribute to create",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"201": {
description: "Contact attribute created successfully.",
},
},
};
export const contactAttributePaths: ZodOpenApiPathsObject = {
"/contact-attributes": {
get: getContactAttributesEndpoint,
post: createContactAttributeEndpoint,
},
"/contact-attributes/{id}": {
get: getContactAttributeEndpoint,
put: updateContactAttributeEndpoint,
delete: deleteContactAttributeEndpoint,
},
};

View File

@@ -0,0 +1,34 @@
import { z } from "zod";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const ZGetContactAttributesFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactAttributeInput = ZContactAttribute.pick({
attributeKeyId: true,
contactId: true,
value: true,
}).openapi({
ref: "contactAttributeInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeInput = z.infer<typeof ZContactAttributeInput>;

View File

@@ -0,0 +1,79 @@
import { ZContactInput } from "@/modules/api/v2/management/contacts/types/contacts";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactEndpoint: ZodOpenApiOperationObject = {
operationId: "getContact",
summary: "Get a contact",
description: "Gets a contact from the database.",
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
tags: ["Management API > Contacts"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const deleteContactEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContact",
summary: "Delete a contact",
description: "Deletes a contact from the database.",
tags: ["Management API > Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const updateContactEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContact",
summary: "Update a contact",
description: "Updates a contact in the database.",
tags: ["Management API > Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};

View File

@@ -0,0 +1,67 @@
import {
deleteContactEndpoint,
getContactEndpoint,
updateContactEndpoint,
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactsEndpoint: ZodOpenApiOperationObject = {
operationId: "getContacts",
summary: "Get contacts",
description: "Gets contacts from the database.",
requestParams: {
query: ZGetContactsFilter,
},
tags: ["Management API > Contacts"],
responses: {
"200": {
description: "Contacts retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContact),
},
},
},
},
};
export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description: "Creates a contact in the database.",
tags: ["Management API > Contacts"],
requestBody: {
required: true,
description: "The contact to create",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"201": {
description: "Contact created successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
get: getContactsEndpoint,
post: createContactEndpoint,
},
"/contacts/{id}": {
get: getContactEndpoint,
put: updateContactEndpoint,
delete: deleteContactEndpoint,
},
};

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { ZContact } from "@formbricks/database/zod/contact";
export const ZGetContactsFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactInput = ZContact.pick({
userId: true,
environmentId: true,
})
.partial({
userId: true,
})
.openapi({
ref: "contactCreate",
description: "A contact to create",
});
export type TContactInput = z.infer<typeof ZContactInput>;

View File

@@ -0,0 +1,44 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string) => {
const hashedKey = hashApiKey(apiKey);
return cache(
async (): Promise<Result<string, ApiErrorResponseV2>> => {
if (!apiKey) {
return err({
type: "bad_request",
details: [{ field: "apiKey", issue: "API key cannot be null or undefined." }],
});
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
select: {
environmentId: true,
},
});
if (!apiKeyData) {
return err({ type: "not_found", details: [{ field: "apiKey", issue: "not found" }] });
}
return ok(apiKeyData.environmentId);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "apiKey", issue: error.message }] });
}
},
[`management-api-getEnvironmentIdFromApiKey-${hashedKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});

View File

@@ -0,0 +1,16 @@
import { fetchEnvironmentId } from "@/modules/api/v2/management/lib/services";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Result, ok } from "@formbricks/types/error-handlers";
export const getEnvironmentId = async (
id: string,
isResponseId: boolean
): Promise<Result<string, ApiErrorResponseV2>> => {
const result = await fetchEnvironmentId(id, isResponseId);
if (!result.ok) {
return result;
}
return ok(result.data.environmentId);
};

View File

@@ -0,0 +1,22 @@
import {
deleteResponseEndpoint,
getResponseEndpoint,
updateResponseEndpoint,
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import {
createResponseEndpoint,
getResponsesEndpoint,
} from "@/modules/api/v2/management/responses/lib/openapi";
import { ZodOpenApiPathsObject } from "zod-openapi";
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,
},
};

View File

@@ -0,0 +1,43 @@
"use server";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: boolean) =>
cache(
async (): Promise<Result<{ environmentId: string }, ApiErrorResponseV2>> => {
try {
const result = await prisma.survey.findFirst({
where: isResponseId ? { responses: { some: { id } } } : { id },
select: {
environmentId: true,
},
});
if (!result) {
return err({
type: "not_found",
details: [{ field: isResponseId ? "response" : "survey", issue: "not found" }],
});
}
return ok({ environmentId: result.environmentId });
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: isResponseId ? "response" : "survey", issue: error.message }],
});
}
},
[`services-getEnvironmentId-${id}-${isResponseId}`],
{
tags: [responseCache.tag.byId(id), responseNoteCache.tag.byResponseId(id), surveyCache.tag.byId(id)],
}
)()
);

View File

@@ -0,0 +1,2 @@
export const apiKey = "test-api-key";
export const environmentId = "h8bfgyetrmvdh5v4cvexogd9";

View File

@@ -0,0 +1,81 @@
import { apiKey, environmentId } from "./__mocks__/api-key.mock";
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn((input: string) => `hashed-${input}`),
}));
describe("getEnvironmentIdFromApiKey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns a bad_request error if apiKey is empty", async () => {
const result = await getEnvironmentIdFromApiKey("");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([
{ field: "apiKey", issue: "API key cannot be null or undefined." },
]);
}
});
test("returns a not_found error when no apiKey record is found in the database", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
expect(result.error.details).toEqual([{ field: "apiKey", issue: "not found" }]);
}
});
test("returns ok with environmentId when a valid apiKey record is found", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ environmentId });
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(environmentId);
}
});
test("returns internal_server_error when an exception occurs during the database lookup", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(new Error("Database failure"));
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "apiKey", issue: "Database failure" }]);
}
});
});

View File

@@ -0,0 +1,43 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { getEnvironmentId } from "../helper";
import { fetchEnvironmentId } from "../services";
vi.mock("../services", () => ({
fetchEnvironmentId: vi.fn(),
}));
describe("Helper Functions", () => {
it("should return environmentId for surveyId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
const result = await getEnvironmentId("survey-id", false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe("env-id");
}
});
it("should return environmentId for responseId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
const result = await getEnvironmentId("response-id", true);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe("env-id");
}
});
it("should return error if getSurveyAndEnvironmentId fails", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(
err({ type: "not_found" } as unknown as ApiErrorResponseV2)
);
const result = await getEnvironmentId("invalid-id", true);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
});

View File

@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { fetchEnvironmentId } from "../services";
vi.mock("@formbricks/database", () => ({
prisma: {
survey: { findFirst: vi.fn() },
},
}));
describe("Services", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getSurveyAndEnvironmentId", () => {
test("should return surveyId and environmentId for responseId", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
environmentId: "env-id",
responses: [{ surveyId: "survey-id" }],
});
const result = await fetchEnvironmentId("response-id", true);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ environmentId: "env-id" });
}
});
test("should return surveyId and environmentId for surveyId", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
id: "survey-id",
environmentId: "env-id",
});
const result = await fetchEnvironmentId("survey-id", false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({ environmentId: "env-id" });
}
});
test("should return error if response is not found", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue(null);
const result = await fetchEnvironmentId("invalid-id", true);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
test("should return error if survey is not found", async () => {
vi.mocked(prisma.survey.findFirst).mockResolvedValue(null);
const result = await fetchEnvironmentId("invalid-id", false);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
}
});
test("should return internal_server_error if prisma query fails for responseId", async () => {
vi.mocked(prisma.survey.findFirst).mockRejectedValue(new Error("Internal server error"));
const result = await fetchEnvironmentId("response-id", true);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
test("should return internal_server_error if prisma query fails for surveyId", async () => {
vi.mocked(prisma.survey.findFirst).mockRejectedValue(new Error("Internal server error"));
const result = await fetchEnvironmentId("survey-id", false);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
});

View File

@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { hashApiKey } from "../utils";
describe("hashApiKey", () => {
test("generate the correct sha256 hash for a given input", () => {
const input = "test";
const expectedHash = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
const result = hashApiKey(input);
expect(result).toEqual(expectedHash);
});
test("return a string with length 64", () => {
const input = "another-api-key";
const result = hashApiKey(input);
expect(result).toHaveLength(64);
});
test("produce the same hash for identical inputs", () => {
const input = "consistentKey";
const firstHash = hashApiKey(input);
const secondHash = hashApiKey(input);
expect(firstHash).toEqual(secondHash);
});
test("generate different hashes for different inputs", () => {
const hash1 = hashApiKey("key1");
const hash2 = hashApiKey("key2");
expect(hash1).not.toEqual(hash2);
});
});

View File

@@ -0,0 +1,3 @@
import { createHash } from "crypto";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");

View File

@@ -0,0 +1,42 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { prisma } from "@formbricks/database";
import { displayCache } from "@formbricks/lib/display/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const deleteDisplay = async (displayId: string): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const display = await prisma.display.delete({
where: {
id: displayId,
},
select: {
id: true,
contactId: true,
surveyId: true,
},
});
displayCache.revalidate({
id: display.id,
contactId: display.contactId,
surveyId: display.surveyId,
});
return ok(true);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2016" || error.code === "P2025") {
return err({
type: "not_found",
details: [{ field: "display", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "display", issue: error.message }],
});
}
};

View File

@@ -0,0 +1,80 @@
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
import { ZResponseInput } from "@formbricks/types/responses";
export const getResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "getResponse",
summary: "Get a response",
description: "Gets a response from the database.",
requestParams: {
path: z.object({
id: responseIdSchema,
}),
},
tags: ["Management API > Responses"],
responses: {
"200": {
description: "Response retrieved successfully.",
content: {
"application/json": {
schema: ZResponse,
},
},
},
},
};
export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteResponse",
summary: "Delete a response",
description: "Deletes a response from the database.",
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: responseIdSchema,
}),
},
responses: {
"200": {
description: "Response deleted successfully.",
content: {
"application/json": {
schema: ZResponse,
},
},
},
},
};
export const updateResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "updateResponse",
summary: "Update a response",
description: "Updates a response in the database.",
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: responseIdSchema,
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZResponseInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZResponse,
},
},
},
},
};

View File

@@ -0,0 +1,131 @@
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
import { responseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getResponse = reactCache(async (responseId: string) =>
cache(
async (): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
});
if (!responsePrisma) {
return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] });
}
return ok(responsePrisma);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "response", issue: error.message }],
});
}
},
[`management-getResponse-${responseId}`],
{
tags: [responseCache.tag.byId(responseId), responseNoteCache.tag.byResponseId(responseId)],
}
)()
);
export const deleteResponse = async (responseId: string): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const deletedResponse = await prisma.response.delete({
where: {
id: responseId,
},
});
if (deletedResponse.displayId) {
const deleteDisplayResult = await deleteDisplay(deletedResponse.displayId);
if (!deleteDisplayResult.ok) {
return deleteDisplayResult;
}
}
const surveyQuestionsResult = await getSurveyQuestions(deletedResponse.surveyId);
if (!surveyQuestionsResult.ok) {
return surveyQuestionsResult;
}
await findAndDeleteUploadedFilesInResponse(deletedResponse.data, surveyQuestionsResult.data.questions);
responseCache.revalidate({
environmentId: surveyQuestionsResult.data.environmentId,
id: deletedResponse.id,
surveyId: deletedResponse.surveyId,
});
responseNoteCache.revalidate({
responseId: deletedResponse.id,
});
return ok(deletedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2016" || error.code === "P2025") {
return err({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "response", issue: error.message }],
});
}
};
export const updateResponse = async (
responseId: string,
responseInput: z.infer<typeof responseUpdateSchema>
): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const updatedResponse = await prisma.response.update({
where: {
id: responseId,
},
data: responseInput,
});
responseCache.revalidate({
id: updatedResponse.id,
surveyId: updatedResponse.surveyId,
});
responseNoteCache.revalidate({
responseId: updatedResponse.id,
});
return ok(updatedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2016" || error.code === "P2025") {
return err({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "response", issue: error.message }],
});
}
};

View File

@@ -0,0 +1,37 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Survey } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSurveyQuestions = reactCache(async (surveyId: string) =>
cache(
async (): Promise<Result<Pick<Survey, "questions" | "environmentId">, ApiErrorResponseV2>> => {
try {
const survey = await prisma.survey.findUnique({
where: {
id: surveyId,
},
select: {
environmentId: true,
questions: true,
},
});
if (!survey) {
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
}
return ok(survey);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
}
},
[`management-getSurveyQuestions-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,13 @@
import { Display } from "@prisma/client";
export const mockDisplay: Display = {
id: "jcvb2vzt7ok3ftjsds4gt1gm",
createdAt: new Date(),
updatedAt: new Date(),
contactId: "con_1",
surveyId: "rp2di001zicbm3mk8je1ue9u",
responseId: "ka4lox8ehrcafhd1753g8szv",
status: "responded",
};
export const displayId = "jcvb2vzt7ok3ftjsds4gt1gm";

View File

@@ -0,0 +1,39 @@
import { Response, Survey } from "@prisma/client";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export const responseId = "goy9hd7uautij04aosslsplb";
export const responseInput: Omit<Response, "id"> = {
data: { file: "fileUrl" },
surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
displayId: "jowdit1qrf04t97jcc0io9di",
createdAt: new Date(),
updatedAt: new Date(),
finished: true,
contactAttributes: {},
contactId: "olwablfltg9eszoh0nz83w02",
endingId: "i4k59a2m6fk70vwpn2d9b7a7",
variables: [],
ttc: {},
language: "en",
meta: {},
singleUseId: "4c02dc5f-eff1-4020-9a9b-a16efd929653",
};
export const response: Response = {
id: responseId,
...responseInput,
};
export const survey: Pick<Survey, "questions" | "environmentId"> = {
questions: [
{
id: "ggaw04zw7gx7uxodk5da7if8",
type: TSurveyQuestionTypeEnum.FileUpload,
headline: { en: "Question 1" },
required: true,
allowMultipleFiles: true,
},
],
environmentId: "z5t8e52wy6xvi61ubebs2e4i",
};

View File

@@ -0,0 +1,18 @@
import { Survey } from "@prisma/client";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export const survey: Pick<Survey, "id" | "questions"> = {
id: "rp2di001zicbm3mk8je1ue9u",
questions: [
{
id: "i0e9y9ya4pl9iyrurlrak3yq",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question Text", de: "Fragetext" },
required: false,
inputType: "text",
charLimit: {
enabled: false,
},
},
],
};

View File

@@ -0,0 +1,33 @@
import { Response, Survey } from "@prisma/client";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export const environmentId = "u8qa6u0tlxb6160pi2jb8s4p";
export const openTextQuestion: Survey["questions"][number] = {
id: "y3ydd3td2iq09wa599cxo1md",
type: TSurveyQuestionTypeEnum.OpenText,
charLimit: {
enabled: true,
},
inputType: "text",
required: true,
headline: { en: "Open Text Question" },
insightsEnabled: true,
};
export const fileUploadQuestion: Survey["questions"][number] = {
id: "y3ydd3td2iq09wa599cxo1me",
type: TSurveyQuestionTypeEnum.FileUpload,
headline: { en: "File Upload Question" },
required: true,
allowMultipleFiles: true,
buttonLabel: { en: "Upload" },
};
export const responseData: Response["data"] = {
[openTextQuestion.id]: "Open Text Answer",
[fileUploadQuestion.id]: [
`https://example.com/dummy/${environmentId}/private/file1.png`,
`https://example.com/dummy/${environmentId}/private/file2.pdf`,
],
};

View File

@@ -0,0 +1,72 @@
import { displayId, mockDisplay } from "./__mocks__/display.mock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { deleteDisplay } from "../display";
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
delete: vi.fn(),
},
},
}));
describe("Display Lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("delete the display successfully ", async () => {
vi.mocked(prisma.display.delete).mockResolvedValue(mockDisplay);
const result = await deleteDisplay(mockDisplay.id);
expect(prisma.display.delete).toHaveBeenCalledWith({
where: { id: mockDisplay.id },
select: {
id: true,
contactId: true,
surveyId: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(true);
}
});
test("return a not_found error when the display is not found", async () => {
vi.mocked(prisma.display.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Display not found", {
code: "P2025",
clientVersion: "1.0.0",
meta: {
cause: "Display not found",
},
})
);
const result = await deleteDisplay(mockDisplay.id);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "display", issue: "not found" }],
});
}
});
test("return an internal_server_error when prisma.display.delete throws", async () => {
vi.mocked(prisma.display.delete).mockRejectedValue(new Error("Delete error"));
const result = await deleteDisplay(displayId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "display", issue: "Delete error" }],
});
}
});
});

View File

@@ -0,0 +1,225 @@
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ok, okVoid } from "@formbricks/types/error-handlers";
import { deleteDisplay } from "../display";
import { deleteResponse, getResponse, updateResponse } from "../response";
import { getSurveyQuestions } from "../survey";
import { findAndDeleteUploadedFilesInResponse } from "../utils";
vi.mock("../display", () => ({
deleteDisplay: vi.fn(),
}));
vi.mock("../survey", () => ({
getSurveyQuestions: vi.fn(),
}));
vi.mock("../utils", () => ({
findAndDeleteUploadedFilesInResponse: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
findUnique: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
},
display: {
delete: vi.fn(),
},
survey: {
findUnique: vi.fn(),
},
},
}));
describe("Response Lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getResponse", () => {
test("return the response when found", async () => {
vi.mocked(prisma.response.findUnique).mockResolvedValue(response);
const result = await getResponse(responseId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
expect(prisma.response.findUnique).toHaveBeenCalledWith({
where: { id: responseId },
});
});
test("return a not_found error when the response is missing", async () => {
vi.mocked(prisma.response.findUnique).mockResolvedValue(null);
const result = await getResponse(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
});
}
});
test("return an internal_server_error when prisma throws an error", async () => {
vi.mocked(prisma.response.findUnique).mockRejectedValue(new Error("DB error"));
const result = await getResponse(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "response", issue: "DB error" }],
});
}
});
});
describe("deleteResponse", () => {
test("delete the response, delete the display and remove uploaded files", async () => {
vi.mocked(prisma.response.delete).mockResolvedValue(response);
vi.mocked(deleteDisplay).mockResolvedValue(ok(true));
vi.mocked(getSurveyQuestions).mockResolvedValue(ok(survey));
vi.mocked(findAndDeleteUploadedFilesInResponse).mockResolvedValue(okVoid());
const result = await deleteResponse(responseId);
expect(prisma.response.delete).toHaveBeenCalledWith({
where: { id: responseId },
});
expect(deleteDisplay).toHaveBeenCalledWith(response.displayId);
expect(getSurveyQuestions).toHaveBeenCalledWith(response.surveyId);
expect(findAndDeleteUploadedFilesInResponse).toHaveBeenCalledWith(response.data, survey.questions);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("return an error if deleteDisplay fails", async () => {
vi.mocked(prisma.response.findUnique).mockResolvedValue(response);
vi.mocked(prisma.response.delete).mockResolvedValue(response);
vi.mocked(deleteDisplay).mockResolvedValue({
ok: false,
error: { type: "internal_server_error", details: [{ field: "display", issue: "delete failed" }] },
});
const result = await deleteResponse(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "display", issue: "delete failed" }],
});
}
});
test("return an error if getSurveyQuestions fails", async () => {
vi.mocked(prisma.response.findUnique).mockResolvedValue(response);
vi.mocked(prisma.response.delete).mockResolvedValue(response);
vi.mocked(deleteDisplay).mockResolvedValue(ok(true));
vi.mocked(getSurveyQuestions).mockResolvedValue({
ok: false,
error: { type: "not_found", details: [{ field: "survey", issue: "not found" }] },
});
const result = await deleteResponse(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
});
}
});
test("catch exceptions and return an internal_server_error", async () => {
vi.mocked(prisma.response.delete).mockRejectedValue(new Error("Unexpected error"));
const result = await deleteResponse(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "response", issue: "Unexpected error" }],
});
}
});
test("handle prisma client error code P2025", async () => {
vi.mocked(prisma.response.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
code: "P2025",
clientVersion: "1.0.0",
meta: {
cause: "Response not found",
},
})
);
const result = await deleteResponse(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
});
}
});
});
describe("updateResponse", () => {
test("update the response and revalidate caches", async () => {
vi.mocked(prisma.response.update).mockResolvedValue(response);
const result = await updateResponse(responseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith({
where: { id: responseId },
data: responseInput,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
code: "P2025",
clientVersion: "1.0.0",
meta: {
cause: "Response not found",
},
})
);
const result = await updateResponse(responseId, responseInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
});
}
});
test("return an error when prisma.response.update throws", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(new Error("Update failed"));
const result = await updateResponse(responseId, responseInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "response", issue: "Update failed" }],
});
}
});
});
});

View File

@@ -0,0 +1,63 @@
import { survey } from "./__mocks__/survey.mock";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getSurveyQuestions } from "../survey";
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
},
},
}));
describe("Survey Lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getSurveyQuestions", () => {
test("return survey questions and environmentId when the survey is found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(survey);
const result = await getSurveyQuestions(survey.id);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: survey.id },
select: {
environmentId: true,
questions: true,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(survey);
}
});
test("return a not_found error when the survey does not exist", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
const result = await getSurveyQuestions(survey.id);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
});
}
});
test("return an internal_server_error when prisma.survey.findUnique throws an error", async () => {
vi.mocked(prisma.survey.findUnique).mockRejectedValue(new Error("DB error"));
const result = await getSurveyQuestions(survey.id);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "survey", issue: "DB error" }],
});
}
});
});
});

View File

@@ -0,0 +1,61 @@
import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { deleteFile } from "@formbricks/lib/storage/service";
import { okVoid } from "@formbricks/types/error-handlers";
import { findAndDeleteUploadedFilesInResponse } from "../utils";
vi.mock("@formbricks/lib/storage/service", () => ({
deleteFile: vi.fn(),
}));
describe("findAndDeleteUploadedFilesInResponse", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("delete files for file upload questions and return okVoid", async () => {
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
expect(deleteFile).toHaveBeenCalledTimes(2);
expect(deleteFile).toHaveBeenCalledWith(environmentId, "private", "file1.png");
expect(deleteFile).toHaveBeenCalledWith(environmentId, "private", "file2.pdf");
expect(result).toEqual(okVoid());
});
test("not call deleteFile if no file upload questions match response data", async () => {
const result = await findAndDeleteUploadedFilesInResponse(responseData, [openTextQuestion]);
expect(deleteFile).not.toHaveBeenCalled();
expect(result).toEqual(okVoid());
});
test("handle invalid file URLs and log errors", async () => {
const invalidFileUrl = "https://example.com/invalid-url";
const responseData = {
[fileUploadQuestion.id]: [invalidFileUrl],
};
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
expect(deleteFile).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalled();
expect(result).toEqual(okVoid());
consoleErrorSpy.mockRestore();
});
test("process multiple file URLs", async () => {
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
expect(deleteFile).toHaveBeenCalledTimes(2);
expect(deleteFile).toHaveBeenNthCalledWith(1, environmentId, "private", "file1.png");
expect(deleteFile).toHaveBeenNthCalledWith(2, environmentId, "private", "file2.pdf");
expect(result).toEqual(okVoid());
});
});

View File

@@ -0,0 +1,36 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response, Survey } from "@prisma/client";
import { deleteFile } from "@formbricks/lib/storage/service";
import { Result, okVoid } from "@formbricks/types/error-handlers";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export const findAndDeleteUploadedFilesInResponse = async (
responseData: Response["data"],
questions: Survey["questions"]
): Promise<Result<void, ApiErrorResponseV2>> => {
const fileUploadQuestions = new Set(
questions.filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload).map((q) => q.id)
);
const fileUrls = Object.entries(responseData)
.filter(([questionId]) => fileUploadQuestions.has(questionId))
.flatMap(([, questionResponse]) => questionResponse as string[]);
const deletionPromises = fileUrls.map(async (fileUrl) => {
try {
const { pathname } = new URL(fileUrl);
const [, environmentId, accessType, fileName] = pathname.split("/").filter(Boolean);
if (!environmentId || !accessType || !fileName) {
throw new Error(`Invalid file path: ${pathname}`);
}
return deleteFile(environmentId, accessType as "private" | "public", fileName);
} catch (error) {
console.error(`Failed to delete file ${fileUrl}:`, error);
}
});
await Promise.all(deletionPromises);
return okVoid();
};

View File

@@ -0,0 +1,135 @@
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import {
deleteResponse,
getResponse,
updateResponse,
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { z } from "zod";
import { responseIdSchema, responseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: responseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.responseId, true);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const response = await getResponse(params.responseId);
if (!response.ok) {
return handleApiError(request, response.error);
}
return responses.successResponse({ data: response.data });
},
});
export const DELETE = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: responseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.responseId, true);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const response = await deleteResponse(params.responseId);
if (!response.ok) {
return handleApiError(request, response.error);
}
return responses.successResponse({ data: response.data });
},
});
export const PUT = (request: Request, props: { params: Promise<{ responseId: string }> }) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: z.object({ responseId: responseIdSchema }),
body: responseUpdateSchema,
},
handler: async ({ authentication, parsedInput }) => {
const { body, params } = parsedInput;
if (!body || !params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.responseId, true);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const response = await updateResponse(params.responseId, body);
if (!response.ok) {
return handleApiError(request, response.error);
}
return responses.successResponse({ data: response.data });
},
});

View File

@@ -0,0 +1,25 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
extendZodWithOpenApi(z);
export const responseIdSchema = z
.string()
.cuid2()
.openapi({
ref: "responseId",
description: "The ID of the response",
param: {
name: "id",
in: "path",
},
});
export const responseUpdateSchema = ZResponse.omit({
id: true,
surveyId: true,
}).openapi({
ref: "responseUpdate",
description: "A response to update.",
});

View File

@@ -0,0 +1,67 @@
import {
deleteResponseEndpoint,
getResponseEndpoint,
updateResponseEndpoint,
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import { ZGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse, ZResponseInput } from "@formbricks/types/responses";
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
operationId: "getResponses",
summary: "Get responses",
description: "Gets responses from the database.",
requestParams: {
query: ZGetResponsesFilter.sourceType().required(),
},
tags: ["Management API > Responses"],
responses: {
"200": {
description: "Responses retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZResponse),
},
},
},
},
};
export const createResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "createResponse",
summary: "Create a response",
description: "Creates a response in the database.",
tags: ["Management API > Responses"],
requestBody: {
required: true,
description: "The response to create",
content: {
"application/json": {
schema: ZResponseInput,
},
},
},
responses: {
"201": {
description: "Response created successfully.",
content: {
"application/json": {
schema: ZResponse,
},
},
},
},
};
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,
},
};

View File

@@ -0,0 +1,184 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentId: string) =>
cache(
async (): Promise<Result<string, ApiErrorResponseV2>> => {
try {
const organization = await prisma.organization.findFirst({
where: {
projects: {
some: {
environments: {
some: {
id: environmentId,
},
},
},
},
},
select: {
id: true,
},
});
if (!organization) {
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
}
return ok(organization.id);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: error.message }],
});
}
},
[`management-getOrganizationIdFromEnvironmentId-${environmentId}`],
{
tags: [organizationCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
cache(
async (): Promise<Result<Pick<Organization, "billing">, ApiErrorResponseV2>> => {
try {
const organization = await prisma.organization.findFirst({
where: {
id: organizationId,
},
select: {
billing: true,
},
});
if (!organization) {
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
}
return ok(organization);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: error.message }],
});
}
},
[`management-getOrganizationBilling-${organizationId}`],
{
tags: [organizationCache.tag.byId(organizationId)],
}
)()
);
export const getAllEnvironmentsFromOrganizationId = reactCache(async (organizationId: string) =>
cache(
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
try {
const organization = await prisma.organization.findUnique({
where: {
id: organizationId,
},
select: {
projects: {
select: {
environments: {
select: {
id: true,
},
},
},
},
},
});
if (!organization) {
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
}
const environmentIds = organization.projects
.flatMap((project) => project.environments)
.map((environment) => environment.id);
return ok(environmentIds);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: error.message }],
});
}
},
[`management-getAllEnvironmentsFromOrganizationId-${organizationId}`],
{
tags: [organizationCache.tag.byId(organizationId)],
}
)()
);
export const getMonthlyOrganizationResponseCount = reactCache(async (organizationId: string) =>
cache(
async (): Promise<Result<number, ApiErrorResponseV2>> => {
try {
const organization = await getOrganizationBilling(organizationId);
if (!organization.ok) {
return err(organization.error);
}
// Determine the start date based on the plan type
let startDate: Date;
if (organization.data.billing.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!organization.data.billing.periodStart) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
}
startDate = organization.data.billing.periodStart;
}
// Get all environment IDs for the organization
const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId);
if (!environmentIdsResult.ok) {
return err(environmentIdsResult.error);
}
// Use Prisma's aggregate to count responses for all environments
const responseAggregations = await prisma.response.aggregate({
_count: {
id: true,
},
where: {
AND: [
{ survey: { environmentId: { in: environmentIdsResult.data } } },
{ createdAt: { gte: startDate } },
],
},
});
// The result is an aggregation of the total count
return ok(responseAggregations._count.id);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: error.message }],
});
}
},
[`management-getMonthlyOrganizationResponseCount-${organizationId}`],
{
revalidate: 60 * 60 * 2, // 2 hours
}
)()
);

View File

@@ -0,0 +1,153 @@
import "server-only";
import {
getMonthlyOrganizationResponseCount,
getOrganizationBilling,
getOrganizationIdFromEnvironmentId,
} from "@/modules/api/v2/management/responses/lib/organization";
import { getResponsesQuery } from "@/modules/api/v2/management/responses/lib/utils";
import { TGetResponsesFilter, TResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { responseCache } from "@formbricks/lib/response/cache";
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const createResponse = async (
environmentId: string,
responseInput: TResponseInput
): Promise<Result<Response, ApiErrorResponseV2>> => {
captureTelemetry("response created");
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
ttc: initialTtc,
createdAt,
updatedAt,
endingId,
} = responseInput;
try {
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData: Prisma.ResponseCreateInput = {
survey: {
connect: {
id: surveyId,
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished,
data,
language,
meta,
singleUseId,
variables,
ttc,
createdAt,
updatedAt,
endingId,
};
const organizationIdResult = await getOrganizationIdFromEnvironmentId(environmentId);
if (!organizationIdResult.ok) {
return err(organizationIdResult.error);
}
const organizationResult = await getOrganizationBilling(organizationIdResult.data);
if (!organizationResult.ok) {
return err(organizationResult.error);
}
const organization = organizationResult.data;
const response = await prisma.response.create({
data: prismaData,
});
responseCache.revalidate({
environmentId,
id: response.id,
...(singleUseId && { singleUseId }),
surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
if (IS_FORMBRICKS_CLOUD) {
const responsesCountResult = await getMonthlyOrganizationResponseCount(organizationIdResult.data);
if (!responsesCountResult.ok) {
return err(responsesCountResult.error);
}
const responsesCount = responsesCountResult.data;
const responsesLimit = organization.billing.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw it
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
}
}
}
return ok(response);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "response", issue: error.message }] });
}
};
export const getResponses = async (
environmentId: string,
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
try {
const [responses, count] = await prisma.$transaction([
prisma.response.findMany({
...getResponsesQuery(environmentId, params),
}),
prisma.response.count({
where: getResponsesQuery(environmentId, params).where,
}),
]);
if (!responses) {
return err({ type: "not_found", details: [{ field: "responses", issue: "not found" }] });
}
return ok({
data: responses,
meta: {
total: count,
limit: params.limit,
offset: params.skip,
},
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "responses", issue: error.message }] });
}
};

View File

@@ -0,0 +1,30 @@
import { Organization } from "@prisma/client";
export const organizationId = "zo6u7apbattt8dquvzbgjjwb";
export const environmentId = "oh5cq6yu418itha55vsuj47e";
export const organizationBilling: Organization["billing"] = {
stripeCustomerId: "cus_P78901234567890123456789",
plan: "scale",
period: "monthly",
limits: {
monthly: { responses: 100, miu: 1000 },
projects: 1,
},
periodStart: new Date(),
};
export const organizationEnvironments = {
projects: [
{
environments: [{ id: "w6pljnz4l9ljgmyl51xv8ah8" }, { id: "v5sfypq4ib6vjelccho23lmn" }],
},
{ environments: [{ id: "ffbv7bmhs52yd8beebu6be2l" }] },
],
};
export const environmentIds = [
"w6pljnz4l9ljgmyl51xv8ah8",
"v5sfypq4ib6vjelccho23lmn",
"ffbv7bmhs52yd8beebu6be2l",
];

View File

@@ -0,0 +1,96 @@
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { Organization, Response } from "@prisma/client";
export const responseInput: Omit<Response, "id"> = {
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
displayId: "cgt5e6dw1vsf1bv2ki5gj845",
finished: true,
data: { key: "value" },
language: "en",
meta: {},
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
variables: {},
ttc: { sample: 1 },
createdAt: new Date(),
updatedAt: new Date(),
endingId: "lowzqpqnmjbmjowvth1u87wp",
contactAttributes: {},
contactId: null,
};
export const responseInputNotFinished: Omit<Response, "id"> = {
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
displayId: "cgt5e6dw1vsf1bv2ki5gj845",
finished: false,
data: { key: "value" },
language: "en",
meta: {},
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
variables: {},
ttc: { sample: 1 },
createdAt: new Date(),
updatedAt: new Date(),
endingId: "lowzqpqnmjbmjowvth1u87wp",
contactAttributes: {},
contactId: null,
};
export const responseInputWithoutTtc: Omit<Response, "id"> = {
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
displayId: "cgt5e6dw1vsf1bv2ki5gj845",
finished: false,
data: { key: "value" },
language: "en",
meta: {},
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
variables: {},
ttc: null,
createdAt: new Date(),
updatedAt: new Date(),
endingId: "lowzqpqnmjbmjowvth1u87wp",
contactAttributes: {},
contactId: null,
};
export const responseInputWithoutDisplay: Omit<Response, "id"> = {
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
displayId: null,
finished: false,
data: { key: "value" },
language: "en",
meta: {},
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
variables: {},
ttc: { sample: 1 },
createdAt: new Date(),
updatedAt: new Date(),
endingId: "lowzqpqnmjbmjowvth1u87wp",
contactAttributes: {},
contactId: null,
};
export const response: Response = {
id: "bauptoqxslg42k7axss0q146",
...responseInput,
};
export const environmentId = "ou9sjm7a7qnilxhhhfszct95";
export const organizationId = "qybv4vk77pw71vnq9rmfrsvi";
export const organizationBilling: Organization["billing"] = {
stripeCustomerId: "cus_P78901234567890123456789",
plan: "free",
period: "monthly",
limits: {
monthly: { responses: 100, miu: 1000 },
projects: 1,
},
periodStart: new Date(),
};
export const responseFilter: TGetResponsesFilter = {
limit: 10,
skip: 0,
sortBy: "createdAt",
order: "asc",
};

View File

@@ -0,0 +1,250 @@
import {
environmentId,
environmentIds,
organizationBilling,
organizationEnvironments,
organizationId,
} from "./__mocks__/organization.mock";
import {
getAllEnvironmentsFromOrganizationId,
getMonthlyOrganizationResponseCount,
getOrganizationBilling,
getOrganizationIdFromEnvironmentId,
} from "@/modules/api/v2/management/responses/lib/organization";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
findUnique: vi.fn(),
},
response: {
aggregate: vi.fn(),
},
},
}));
describe("Organization Lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getOrganizationIdFromEnvironmentId", () => {
test("return organization id when found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ id: organizationId });
const result = await getOrganizationIdFromEnvironmentId(environmentId);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: {
projects: { some: { environments: { some: { id: environmentId } } } },
},
select: { id: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(organizationId);
}
});
test("return a not_found error when organization is not found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
const result = await getOrganizationIdFromEnvironmentId(environmentId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "organization", issue: "not found" }],
});
}
});
test("return an internal_server_error when an exception is thrown", async () => {
const error = new Error("DB error");
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
const result = await getOrganizationIdFromEnvironmentId(environmentId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "organization", issue: "DB error" }],
});
}
});
});
describe("getOrganizationBilling", () => {
test("return organization billing when found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
const result = await getOrganizationBilling(organizationId);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: { id: organizationId },
select: { billing: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.billing).toEqual(organizationBilling);
}
});
test("return a not_found error when organization is not found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
const result = await getOrganizationBilling(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "organization", issue: "not found" }],
});
}
});
test("handle PrismaClientKnownRequestError", async () => {
const error = new Error("DB error");
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
const result = await getOrganizationBilling(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "organization", issue: "DB error" }],
});
}
});
});
describe("getAllEnvironmentsFromOrganizationId", () => {
test("return all environments from organization", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
const result = await getAllEnvironmentsFromOrganizationId(organizationId);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: organizationId },
select: {
projects: {
select: {
environments: { select: { id: true } },
},
},
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(environmentIds);
}
});
test("return a not_found error when organization is not found", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
const result = await getAllEnvironmentsFromOrganizationId(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "organization", issue: "not found" }],
});
}
});
test("return an internal_server_error when an exception is thrown", async () => {
const error = new Error("DB error");
vi.mocked(prisma.organization.findUnique).mockRejectedValue(error);
const result = await getAllEnvironmentsFromOrganizationId(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "organization", issue: "DB error" }],
});
}
});
});
describe("getMonthlyOrganizationResponseCount", () => {
test("return error if getOrganizationBilling returns error", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "organization", issue: "not found" }],
});
}
});
test("return error if billing plan is not free and periodStart is not set", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
billing: { ...organizationBilling, periodStart: null },
});
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
}
});
test("return response count", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
vi.mocked(prisma.response.aggregate).mockResolvedValue({ _count: { id: 5 } });
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(prisma.response.aggregate).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(5);
}
});
test("return for a free plan", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
billing: { ...organizationBilling, plan: "free" },
});
vi.mocked(prisma.response.aggregate).mockResolvedValue({ _count: { id: 5 } });
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(5);
}
});
test("handle internal_server_error in aggregation", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
const error = new Error("Aggregate error");
vi.mocked(prisma.response.aggregate).mockRejectedValue(error);
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "organization", issue: "Aggregate error" }],
});
}
});
test("handle error when getAllEnvironmentsFromOrganizationId fails", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
const result = await getMonthlyOrganizationResponseCount(organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "organization", issue: "not found" }],
});
}
});
});
});

View File

@@ -0,0 +1,261 @@
import {
environmentId,
organizationBilling,
organizationId,
response,
responseFilter,
responseInput,
responseInputNotFinished,
responseInputWithoutDisplay,
responseInputWithoutTtc,
} from "./__mocks__/response.mock";
import {
getMonthlyOrganizationResponseCount,
getOrganizationBilling,
getOrganizationIdFromEnvironmentId,
} from "@/modules/api/v2/management/responses/lib/organization";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { err, ok } from "@formbricks/types/error-handlers";
import { createResponse, getResponses } from "../response";
vi.mock("@formbricks/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationBilling: vi.fn(),
getMonthlyOrganizationResponseCount: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
create: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
IS_PRODUCTION: false,
}));
describe("Response Lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createResponse", () => {
test("create a response successfully", async () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInput);
expect(prisma.response.create).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("handle response for initialTtc not finished", async () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputNotFinished);
expect(prisma.response.create).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("handle response for initialTtc not provided", async () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputWithoutTtc);
expect(prisma.response.create).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("handle response for display not provided", async () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
const result = await createResponse(environmentId, responseInputWithoutDisplay);
expect(prisma.response.create).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("return error if getOrganizationIdFromEnvironmentId fails", async () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(
err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] })
);
const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "organization", issue: "not found" }],
});
}
});
test("return error if getOrganizationBilling fails", async () => {
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(
err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] })
);
const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "organization", issue: "not found" }],
});
}
});
test("send plan limit event when in cloud and responses limit is reached", async () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockImplementation(() => Promise.resolve(""));
const result = await createResponse(environmentId, responseInput);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("handle error getting monthly organization response count", async () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
err({ type: "internal_server_error", details: [{ field: "organization", issue: "Aggregate error" }] })
);
const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "organization", issue: "Aggregate error" }],
});
}
});
test("handle error sending plan limits reached event", async () => {
vi.mocked(prisma.response.create).mockResolvedValue(response);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(
new Error("Error sending plan limits")
);
const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("return an internal_server_error error if prisma create fails", async () => {
vi.mocked(prisma.response.create).mockRejectedValue(new Error("Internal server error"));
const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});
describe("getResponses", () => {
test("return responses with meta information", async () => {
const responses = [response];
prisma.$transaction = vi.fn().mockResolvedValue([responses, responses.length]);
const result = await getResponses(environmentId, responseFilter);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [response],
meta: {
total: responses.length,
limit: responseFilter.limit,
offset: responseFilter.skip,
},
});
}
});
test("return a not_found error if responses are not found", async () => {
prisma.$transaction = vi.fn().mockResolvedValue([null, 0]);
const result = await getResponses(environmentId, responseFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "responses", issue: "not found" }],
});
}
});
test("return an internal_server_error error if prisma transaction fails", async () => {
prisma.$transaction = vi.fn().mockRejectedValue(new Error("Internal server error"));
const result = await getResponses(environmentId, responseFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "responses", issue: "Internal server error" }],
});
}
});
});
});

View File

@@ -0,0 +1,97 @@
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { describe, expect, test } from "vitest";
import { getResponsesQuery } from "../utils";
describe("getResponsesQuery", () => {
const environmentId = "env_1";
const filters: TGetResponsesFilter = {
limit: 10,
skip: 0,
sortBy: "createdAt",
order: "asc",
};
test("return the base query when no params are provided", () => {
const query = getResponsesQuery(environmentId);
expect(query).toEqual({
where: {
survey: { environmentId },
},
});
});
test("add surveyId to the query when provided", () => {
const query = getResponsesQuery(environmentId, { ...filters, surveyId: "survey_1" });
expect(query.where).toEqual({
survey: { environmentId },
surveyId: "survey_1",
});
});
test("add startDate filter to the query", () => {
const startDate = new Date("2023-01-01");
const query = getResponsesQuery(environmentId, { ...filters, startDate });
expect(query.where).toEqual({
survey: { environmentId },
createdAt: { gte: startDate },
});
});
test("add endDate filter to the query", () => {
const endDate = new Date("2023-01-31");
const query = getResponsesQuery(environmentId, { ...filters, endDate });
expect(query.where).toEqual({
survey: { environmentId },
createdAt: { lte: endDate },
});
});
test("add sortBy and order to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, sortBy: "createdAt", order: "desc" });
expect(query.orderBy).toEqual({
createdAt: "desc",
});
});
test("add limit (take) to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, limit: 10 });
expect(query.take).toBe(10);
});
test("add skip to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, skip: 5 });
expect(query.skip).toBe(5);
});
test("add contactId to the query", () => {
const query = getResponsesQuery(environmentId, { ...filters, contactId: "contact_1" });
expect(query.where).toEqual({
survey: { environmentId },
contactId: "contact_1",
});
});
test("combine multiple filters correctly", () => {
const params = {
...filters,
surveyId: "survey_1",
startDate: new Date("2023-01-01"),
endDate: new Date("2023-01-31"),
limit: 20,
skip: 10,
contactId: "contact_1",
};
const query = getResponsesQuery(environmentId, params);
expect(query.where).toEqual({
survey: { environmentId },
surveyId: "survey_1",
createdAt: { lte: params.endDate, gte: params.startDate },
contactId: "contact_1",
});
expect(query.orderBy).toEqual({
createdAt: "asc",
});
expect(query.take).toBe(20);
expect(query.skip).toBe(10);
});
});

View File

@@ -0,0 +1,85 @@
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { Prisma } from "@prisma/client";
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
const { surveyId, limit, skip, sortBy, order, startDate, endDate, contactId } = params || {};
let query: Prisma.ResponseFindManyArgs = {
where: {
survey: {
environmentId,
},
},
};
if (surveyId) {
query = {
...query,
where: {
...query.where,
surveyId,
},
};
}
if (startDate) {
query = {
...query,
where: {
...query.where,
createdAt: {
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
gte: startDate,
},
},
};
}
if (endDate) {
query = {
...query,
where: {
...query.where,
createdAt: {
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
lte: endDate,
},
},
};
}
if (sortBy) {
query = {
...query,
orderBy: {
[sortBy]: order,
},
};
}
if (limit) {
query = {
...query,
take: limit,
};
}
if (skip) {
query = {
...query,
skip: skip,
};
}
if (contactId) {
query = {
...query,
where: {
...query.where,
contactId,
},
};
}
return query;
};

View File

@@ -0,0 +1,83 @@
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { NextRequest } from "next/server";
import { createResponse, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetResponsesFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
if (!query) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "query", issue: "missing" }],
});
}
const environmentId = authentication.environmentId;
const res = await getResponses(environmentId, query);
if (res.ok) {
return responses.successResponse(res.data);
}
return handleApiError(request, res.error);
},
});
export const POST = async (request: Request) =>
authenticatedApiClient({
request,
schemas: {
body: ZResponseInput,
},
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!body) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(body.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
if (body.createdAt && !body.updatedAt) {
body.updatedAt = body.createdAt;
}
const createResponseResult = await createResponse(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error);
}
return responses.successResponse({ data: createResponseResult.data, cors: true });
},
});

View File

@@ -0,0 +1,59 @@
import { z } from "zod";
import { ZResponse } from "@formbricks/database/zod/responses";
export const ZGetResponsesFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
surveyId: z.string().cuid2().optional(),
contactId: z.string().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetResponsesFilter = z.infer<typeof ZGetResponsesFilter>;
export const ZResponseInput = ZResponse.pick({
createdAt: true,
updatedAt: true,
surveyId: true,
displayId: true,
singleUseId: true,
finished: true,
endingId: true,
language: true,
data: true,
variables: true,
ttc: true,
meta: true,
})
.partial({
displayId: true,
singleUseId: true,
endingId: true,
language: true,
variables: true,
ttc: true,
meta: true,
createdAt: true,
updatedAt: true,
})
.openapi({
ref: "responseCreate",
description: "A response to create",
});
export type TResponseInput = z.infer<typeof ZResponseInput>;

View File

@@ -0,0 +1,80 @@
import { surveyIdSchema } from "@/modules/api/v2/management/surveys/[surveyId]/types/survey";
import { ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
export const getSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "getSurvey",
summary: "Get a survey",
description: "Gets a survey from the database.",
requestParams: {
path: z.object({
id: surveyIdSchema,
}),
},
tags: ["Management API > Surveys"],
responses: {
"200": {
description: "Response retrieved successfully.",
content: {
"application/json": {
schema: ZSurveyWithoutQuestionType,
},
},
},
},
};
export const deleteSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteSurvey",
summary: "Delete a survey",
description: "Deletes a survey from the database.",
tags: ["Management API > Surveys"],
requestParams: {
path: z.object({
id: surveyIdSchema,
}),
},
responses: {
"200": {
description: "Response deleted successfully.",
content: {
"application/json": {
schema: ZSurveyWithoutQuestionType,
},
},
},
},
};
export const updateSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateSurvey",
summary: "Update a survey",
description: "Updates a survey in the database.",
tags: ["Management API > Surveys"],
requestParams: {
path: z.object({
id: surveyIdSchema,
}),
},
requestBody: {
required: true,
description: "The survey to update",
content: {
"application/json": {
schema: ZSurveyInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZSurveyWithoutQuestionType,
},
},
},
},
};

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const surveyIdSchema = z
.string()
.cuid2()
.openapi({
ref: "surveyId",
description: "The ID of the survey",
param: {
name: "id",
in: "path",
},
});

View File

@@ -0,0 +1,67 @@
import {
deleteSurveyEndpoint,
getSurveyEndpoint,
updateSurveyEndpoint,
} from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
export const getSurveysEndpoint: ZodOpenApiOperationObject = {
operationId: "getSurveys",
summary: "Get surveys",
description: "Gets surveys from the database.",
requestParams: {
query: ZGetSurveysFilter,
},
tags: ["Management API > Surveys"],
responses: {
"200": {
description: "Surveys retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZSurveyWithoutQuestionType),
},
},
},
},
};
export const createSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "createSurvey",
summary: "Create a survey",
description: "Creates a survey in the database.",
tags: ["Management API > Surveys"],
requestBody: {
required: true,
description: "The survey to create",
content: {
"application/json": {
schema: ZSurveyInput,
},
},
},
responses: {
"201": {
description: "Survey created successfully.",
content: {
"application/json": {
schema: ZSurveyWithoutQuestionType,
},
},
},
},
};
export const surveyPaths: ZodOpenApiPathsObject = {
"/surveys": {
get: getSurveysEndpoint,
post: createSurveyEndpoint,
},
"/surveys/{id}": {
get: getSurveyEndpoint,
put: updateSurveyEndpoint,
delete: deleteSurveyEndpoint,
},
};

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
export const ZGetSurveysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
surveyType: z.enum(["link", "app"]).optional(),
surveyStatus: z.enum(["draft", "scheduled", "inProgress", "paused", "completed"]).optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
name: true,
redirectUrl: true,
type: true,
environmentId: true,
questions: true,
endings: true,
thankYouCard: true,
hiddenFields: true,
variables: true,
displayOption: true,
recontactDays: true,
displayLimit: true,
autoClose: true,
autoComplete: true,
delay: true,
runOnDate: true,
closeOnDate: true,
singleUse: true,
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
inlineTriggers: true,
verifyEmail: true,
displayPercentage: true,
welcomeCard: true,
surveyClosedMessage: true,
styling: true,
projectOverwrites: true,
showLanguageSwitch: true,
})
.partial({
redirectUrl: true,
endings: true,
thankYouCard: true,
variables: true,
recontactDays: true,
displayLimit: true,
autoClose: true,
autoComplete: true,
runOnDate: true,
closeOnDate: true,
surveyClosedMessage: true,
styling: true,
projectOverwrites: true,
showLanguageSwitch: true,
inlineTriggers: true,
verifyEmail: true,
displayPercentage: true,
})
.openapi({
ref: "surveyInput",
description: "A survey input object for creating or updating surveys",
});
export type TSurveyInput = z.infer<typeof ZSurveyInput>;

View File

@@ -0,0 +1,83 @@
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
import { ZResponse } from "@formbricks/database/zod/responses";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
extendZodWithOpenApi(z);
const document = createDocument({
openapi: "3.1.0",
info: {
title: "Formbricks API",
description: "Manage Formbricks resources programmatically.",
version: "2.0.0",
},
paths: {
...responsePaths,
...contactPaths,
...contactAttributePaths,
...contactAttributeKeyPaths,
...surveyPaths,
},
servers: [
{
url: "https://app.formbricks.com/api/v2/management",
description: "Formbricks Cloud",
},
],
tags: [
{
name: "Management API > Responses",
description: "Operations for managing responses.",
},
{
name: "Management API > Contacts",
description: "Operations for managing contacts.",
},
{
name: "Management API > Contact Attributes",
description: "Operations for managing contact attributes.",
},
{
name: "Management API > Contact Attributes Keys",
description: "Operations for managing contact attributes keys.",
},
{
name: "Management API > Surveys",
description: "Operations for managing surveys.",
},
],
components: {
securitySchemes: {
apiKeyAuth: {
type: "apiKey",
in: "header",
name: "x-api-key",
description: "Use your Formbricks x-api-key to authenticate.",
},
},
schemas: {
response: ZResponse,
contact: ZContact,
contactAttribute: ZContactAttribute,
contactAttributeKey: ZContactAttributeKey,
survey: ZSurveyWithoutQuestionType,
},
},
security: [
{
apiKeyAuth: [],
},
],
});
console.log(yaml.stringify(document));

View File

@@ -0,0 +1,11 @@
export type ApiErrorDetails = { field: string; issue: string }[];
export type ApiErrorResponseV2 =
| {
type: "unauthorized" | "forbidden" | "conflict" | "too_many_requests" | "internal_server_error";
details?: ApiErrorDetails;
}
| {
type: "bad_request" | "not_found" | "unprocessable_entity";
details: ApiErrorDetails;
};

View File

@@ -0,0 +1,13 @@
export interface ApiResponse<T = { [key: string]: unknown }> {
data: T;
}
export interface ApiResponseWithMeta<T = { [key: string]: unknown }> extends ApiResponse<T> {
meta?: {
total?: number;
limit?: number;
offset?: number;
};
}
export type ApiSuccessResponse<T = { [key: string]: unknown }> = ApiResponse<T> | ApiResponseWithMeta<T>;

View File

@@ -100,6 +100,7 @@ export const getPersonState = async ({
// If the person exists, return the persons's state
const userState: TJsPersonState["data"] = {
contactId: contact.id,
userId,
segments,
displays:

View File

@@ -58,6 +58,7 @@ export const getUserState = async ({
// If the person exists, return the persons's state
const userState: TJsPersonState["data"] = {
contactId,
userId,
segments,
displays:

View File

@@ -11,7 +11,10 @@
"start": "next start",
"lint": "next lint",
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage"
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
"generate-api-specs": "tsx ./modules/api/v2/openapi-document.ts > ../../docs/api-v2-reference/openapi.yml",
"merge-client-endpoints": "tsx ./scripts/merge-client-endpoints.ts",
"generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints"
},
"dependencies": {
"@ai-sdk/azure": "1.1.9",
@@ -67,6 +70,7 @@
"@tolgee/cli": "2.8.1",
"@tolgee/format-icu": "6.0.1",
"@tolgee/react": "6.0.1",
"@unkey/ratelimit": "0.5.5",
"@vercel/functions": "1.5.2",
"@vercel/og": "0.6.4",
"@vercel/otel": "1.10.0",
@@ -122,9 +126,11 @@
"tailwind-merge": "2.5.5",
"tailwindcss": "3.4.16",
"ua-parser-js": "2.0.0",
"uuid": "11.1.0",
"webpack": "5.97.1",
"xlsx": "0.18.5",
"zod": "3.24.1"
"zod": "3.24.1",
"zod-openapi": "4.2.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -138,8 +144,9 @@
"@types/papaparse": "5.3.15",
"@types/qrcode": "1.5.5",
"@vitest/coverage-v8": "2.1.8",
"vite": "6.0.9",
"vitest": "2.1.9",
"vite": "6.2.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.0.7",
"vitest-mock-extended": "2.0.2"
}
}

View File

@@ -0,0 +1,2 @@
export const RESPONSES_API_URL = `/api/v2/management/responses`;
export const SURVEYS_API_URL = `/api/v1/management/surveys`;

View File

@@ -0,0 +1,447 @@
import { expect } from "@playwright/test";
import { test } from "../../lib/fixtures";
import { loginAndGetApiKey } from "../../lib/utils";
import { RESPONSES_API_URL, SURVEYS_API_URL } from "../constants";
test.describe("API Tests for Responses", () => {
test("Create, Retrieve, Update, and Delete Responses via API", async ({ page, users, request }) => {
let environmentId, apiKey;
try {
({ environmentId, apiKey } = await loginAndGetApiKey(page, users));
} catch (error) {
console.error("Error during login and getting API key:", error);
throw error;
}
let createdResponseId1, createdResponseId2, surveyId: string;
await test.step("Create Survey via API", async () => {
const surveyBody = {
environmentId: environmentId,
type: "link",
name: "My new Survey from API",
questions: [
{
id: "jpvm9b73u06xdrhzi11k2h76",
type: "openText",
headline: {
default: "What would you like to know?",
},
required: true,
inputType: "text",
subheader: {
default: "This is an example survey.",
},
placeholder: {
default: "Type your answer here...",
},
charLimit: {
enabled: false,
},
},
],
};
const response = await request.post(SURVEYS_API_URL, {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
data: surveyBody,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.name).toEqual("My new Survey from API");
surveyId = responseBody.data.id;
});
await test.step("Create First Response via API", async () => {
const responseBody = {
createdAt: "2021-01-01T00:00:00.000Z",
updatedAt: "2021-01-01T00:00:00.000Z",
surveyId: surveyId,
finished: true,
language: "en",
data: {
question1: "answer1",
question2: 2,
question3: ["answer3", "answer4"],
question4: { subquestion1: "answer5" },
},
variables: {
variable1: "answer1",
variable2: 2,
},
ttc: {
question1: 10,
question2: 20,
},
meta: {
source: "https://example.com",
url: "https://example.com",
userAgent: {
browser: "Chrome",
os: "Windows",
device: "Desktop",
},
country: "US",
action: "click",
},
};
const response = await request.post(RESPONSES_API_URL, {
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
data: responseBody,
});
expect(response.ok()).toBe(true);
const responseJson = await response.json();
expect(responseJson.data).toHaveProperty("id");
createdResponseId1 = responseJson.data.id;
});
await test.step("Create Second Response via API", async () => {
const responseBody = {
createdAt: "2021-01-02T00:00:00.000Z",
updatedAt: "2021-01-02T00:00:00.000Z",
surveyId: surveyId,
finished: true,
language: "en",
data: {
question1: "answer2",
question2: 3,
question3: ["answer5", "answer6"],
question4: { subquestion1: "answer7" },
},
variables: {
variable1: "answer2",
variable2: 3,
},
ttc: {
question1: 15,
question2: 25,
},
meta: {
source: "https://example2.com",
url: "https://example2.com",
userAgent: {
browser: "Firefox",
os: "Linux",
device: "Laptop",
},
country: "CA",
action: "submit",
},
};
const response = await request.post(RESPONSES_API_URL, {
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
data: responseBody,
});
expect(response.ok()).toBe(true);
const responseJson = await response.json();
expect(responseJson.data).toHaveProperty("id");
createdResponseId2 = responseJson.data.id;
});
await test.step("Get Responses from API sorting by createdAt desc", async () => {
const queryParams = {
limit: 10,
skip: 0,
sortBy: "createdAt",
order: "desc",
};
const response = await request.get(RESPONSES_API_URL, {
headers: {
"x-api-key": apiKey,
},
params: queryParams,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toBeGreaterThan(0);
const createdResponse1 = responseBody.data.find((resp) => resp.id === createdResponseId1);
const createdResponse2 = responseBody.data.find((resp) => resp.id === createdResponseId2);
expect(createdResponse1).toMatchObject({
createdAt: "2021-01-01T00:00:00.000Z",
updatedAt: "2021-01-01T00:00:00.000Z",
surveyId: surveyId,
finished: true,
language: "en",
data: {
question1: "answer1",
question2: 2,
question3: ["answer3", "answer4"],
question4: { subquestion1: "answer5" },
},
variables: {
variable1: "answer1",
variable2: 2,
},
ttc: {
question1: 10,
question2: 20,
},
meta: {
source: "https://example.com",
url: "https://example.com",
userAgent: {
browser: "Chrome",
os: "Windows",
device: "Desktop",
},
country: "US",
action: "click",
},
});
expect(createdResponse2).toMatchObject({
createdAt: "2021-01-02T00:00:00.000Z",
updatedAt: "2021-01-02T00:00:00.000Z",
surveyId: surveyId,
finished: true,
language: "en",
data: {
question1: "answer2",
question2: 3,
question3: ["answer5", "answer6"],
question4: { subquestion1: "answer7" },
},
variables: {
variable1: "answer2",
variable2: 3,
},
ttc: {
question1: 15,
question2: 25,
},
meta: {
source: "https://example2.com",
url: "https://example2.com",
userAgent: {
browser: "Firefox",
os: "Linux",
device: "Laptop",
},
country: "CA",
action: "submit",
},
});
// Check if the responses are sorted correctly
expect(responseBody.data[0].id).toBe(createdResponseId2);
expect(responseBody.data[1].id).toBe(createdResponseId1);
});
await test.step("Get Responses from API sorting by updatedAt asc", async () => {
const queryParams = {
limit: 10,
skip: 0,
sortBy: "updatedAt",
order: "asc",
};
const response = await request.get(RESPONSES_API_URL, {
headers: {
"x-api-key": apiKey,
},
params: queryParams,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toBeGreaterThan(0);
const createdResponse1 = responseBody.data.find((resp) => resp.id === createdResponseId1);
const createdResponse2 = responseBody.data.find((resp) => resp.id === createdResponseId2);
// Check if the responses are sorted correctly
expect(responseBody.data[0].id).toBe(createdResponseId1);
expect(responseBody.data[1].id).toBe(createdResponseId2);
});
await test.step("Get Responses from API 1 response per page - Page 1", async () => {
const queryParams = {
limit: 1,
skip: 0,
sortBy: "updatedAt",
order: "asc",
};
const response = await request.get(RESPONSES_API_URL, {
headers: {
"x-api-key": apiKey,
},
params: queryParams,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toBe(1);
const createdResponse1 = responseBody.data.find((resp) => resp.id === createdResponseId1);
expect(responseBody.data[0].id).toBe(createdResponseId1);
});
await test.step("Get Responses from API 1 response per page - Page 2", async () => {
const queryParams = {
limit: 1,
skip: 1,
sortBy: "updatedAt",
order: "asc",
};
const response = await request.get(RESPONSES_API_URL, {
headers: {
"x-api-key": apiKey,
},
params: queryParams,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toBe(1);
const createdResponse2 = responseBody.data.find((resp) => resp.id === createdResponseId2);
expect(responseBody.data[0].id).toBe(createdResponse2.id);
});
await test.step("Update Response by ID via API", async () => {
const updatedResponseBody = {
createdAt: "2021-01-01T00:00:00.000Z",
updatedAt: "2021-01-03T00:00:00.000Z",
surveyId: surveyId,
finished: false,
language: "fr",
endingId: null,
contactId: null,
contactAttributes: null,
singleUseId: null,
displayId: null,
data: {
question1: "updatedAnswer1",
question2: 5,
question3: ["updatedAnswer3", "updatedAnswer4"],
question4: { subquestion1: "updatedAnswer5" },
},
variables: {
variable1: "updatedAnswer1",
variable2: 5,
},
ttc: {
question1: 30,
question2: 40,
},
meta: {
source: "https://updatedexample.com",
url: "https://updatedexample.com",
userAgent: {
browser: "Safari",
os: "macOS",
device: "Tablet",
},
country: "FR",
action: "update",
},
};
const response = await request.put(`${RESPONSES_API_URL}/${createdResponseId1}`, {
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
data: updatedResponseBody,
});
expect(response.ok()).toBe(true);
});
await test.step("Get Response by ID from API", async () => {
const response = await request.get(`${RESPONSES_API_URL}/${createdResponseId1}`, {
headers: {
"x-api-key": apiKey,
},
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.id).toEqual(createdResponseId1);
expect(responseBody.data).toMatchObject({
createdAt: "2021-01-01T00:00:00.000Z",
updatedAt: "2021-01-03T00:00:00.000Z",
surveyId: surveyId,
finished: false,
language: "fr",
endingId: null,
contactId: null,
contactAttributes: null,
singleUseId: null,
displayId: null,
data: {
question1: "updatedAnswer1",
question2: 5,
question3: ["updatedAnswer3", "updatedAnswer4"],
question4: { subquestion1: "updatedAnswer5" },
},
variables: {
variable1: "updatedAnswer1",
variable2: 5,
},
ttc: {
question1: 30,
question2: 40,
},
meta: {
source: "https://updatedexample.com",
url: "https://updatedexample.com",
userAgent: {
browser: "Safari",
os: "macOS",
device: "Tablet",
},
country: "FR",
action: "update",
},
});
});
await test.step("Delete Responses via API", async () => {
const response1 = await request.delete(`${RESPONSES_API_URL}/${createdResponseId1}`, {
headers: {
"x-api-key": apiKey,
},
});
expect(response1.ok()).toBe(true);
const response2 = await request.delete(`${RESPONSES_API_URL}/${createdResponseId2}`, {
headers: {
"x-api-key": apiKey,
},
});
expect(response2.ok()).toBe(true);
});
});
});

View File

@@ -1,37 +1,21 @@
import { expect } from "@playwright/test";
import { test } from "../../lib/fixtures";
import { loginAndGetApiKey } from "../../lib/utils";
import { SURVEYS_API_URL } from "../constants";
test.describe("API Tests", () => {
let surveyId: string;
let environmentId: string;
let apiKey: string;
let surveyId, environmentId, apiKey;
test("API Tests", async ({ page, users, request }) => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await test.step("Copy API Key", async () => {
environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
(() => {
throw new Error("Unable to parse environmentId from URL");
})();
await page.goto(`/environments/${environmentId}/project/api-keys`);
await page.getByRole("button", { name: "Add Production API Key" }).isVisible();
await page.getByRole("button", { name: "Add Production API Key" }).click();
await page.getByPlaceholder("e.g. GitHub, PostHog, Slack").fill("E2E Test API Key");
await page.getByRole("button", { name: "Add API Key" }).click();
await page.locator(".copyApiKeyIcon").click();
apiKey = await page.evaluate("navigator.clipboard.readText()");
});
try {
({ environmentId, apiKey } = await loginAndGetApiKey(page, users));
} catch (error) {
console.error("Error during login and getting API key:", error);
throw error;
}
await test.step("Create Survey from API", async () => {
const response = await request.post(`/api/v1/management/surveys`, {
const response = await request.post(SURVEYS_API_URL, {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
@@ -67,10 +51,12 @@ test.describe("API Tests", () => {
const responseBody = await response.json();
expect(responseBody.data.name).toEqual("My new Survey from API");
expect(responseBody.data.environmentId).toEqual(environmentId);
surveyId = responseBody.data.id;
});
await test.step("List Surveys from API", async () => {
const response = await request.get(`/api/v1/management/surveys`, {
const response = await request.get(SURVEYS_API_URL, {
headers: {
"x-api-key": apiKey,
},
@@ -85,7 +71,7 @@ test.describe("API Tests", () => {
});
await test.step("Get Survey by ID from API", async () => {
const responseSurvey = await request.get(`/api/v1/management/surveys/${surveyId}`, {
const responseSurvey = await request.get(`${SURVEYS_API_URL}/${surveyId}`, {
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
@@ -98,7 +84,7 @@ test.describe("API Tests", () => {
});
await test.step("Updated Survey by ID from API", async () => {
const response = await request.put(`/api/v1/management/surveys/${surveyId}`, {
const response = await request.put(`${SURVEYS_API_URL}/${surveyId}`, {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
@@ -114,7 +100,7 @@ test.describe("API Tests", () => {
});
await test.step("Delete Survey by ID from API", async () => {
const response = await request.delete(`/api/v1/management/surveys/${surveyId}`, {
const response = await request.delete(`${SURVEYS_API_URL}/${surveyId}`, {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
@@ -124,7 +110,7 @@ test.describe("API Tests", () => {
const responseBody = await response.json();
expect(responseBody.data.name).toEqual("My updated Survey from API");
const responseSurvey = await request.get(`/api/v1/management/surveys/${surveyId}`, {
const responseSurvey = await request.get(`${SURVEYS_API_URL}/${surveyId}`, {
headers: {
"content-type": "application/json",
"x-api-key": apiKey,

View File

@@ -39,7 +39,18 @@ export const createUserFixture = (
export type UserFixture = ReturnType<typeof createUserFixture>;
export const createUsersFixture = (page: Page, workerInfo: TestInfo) => {
export type UsersFixture = {
create: (params?: {
name?: string;
email?: string;
organizationName?: string;
projectName?: string;
withoutProject?: boolean;
}) => Promise<UserFixture>;
get: () => UserFixture[];
};
export const createUsersFixture = (page: Page, workerInfo: TestInfo): UsersFixture => {
const store: { users: UserFixture[] } = {
users: [],
};

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