mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-16 19:07:16 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
12
.github/workflows/e2e.yml
vendored
12
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
|
||||
15
apps/web/app/api/v1/management/me/lib/utils.ts
Normal file
15
apps/web/app/api/v1/management/me/lib/utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/displays/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route";
|
||||
|
||||
export { OPTIONS, GET };
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
138
apps/web/app/api/v2/client/[environmentId]/responses/route.ts
Normal file
138
apps/web/app/api/v2/client/[environmentId]/responses/route.ts
Normal 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);
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
3
apps/web/app/api/v2/client/[environmentId]/user/route.ts
Normal file
3
apps/web/app/api/v2/client/[environmentId]/user/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
||||
|
||||
export { POST, OPTIONS };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
3
apps/web/app/api/v2/management/responses/route.ts
Normal file
3
apps/web/app/api/v2/management/responses/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/responses/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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*",
|
||||
],
|
||||
};
|
||||
|
||||
70
apps/web/modules/api/v2/lib/rate-limit.ts
Normal file
70
apps/web/modules/api/v2/lib/rate-limit.ts
Normal 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();
|
||||
};
|
||||
270
apps/web/modules/api/v2/lib/response.ts
Normal file
270
apps/web/modules/api/v2/lib/response.ts
Normal 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,
|
||||
};
|
||||
107
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal file
107
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
183
apps/web/modules/api/v2/lib/tests/response.test.ts
Normal file
183
apps/web/modules/api/v2/lib/tests/response.test.ts
Normal 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("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
201
apps/web/modules/api/v2/lib/tests/utils.test.ts
Normal file
201
apps/web/modules/api/v2/lib/tests/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/web/modules/api/v2/lib/utils.ts
Normal file
65
apps/web/modules/api/v2/lib/utils.ts
Normal 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)}`
|
||||
);
|
||||
};
|
||||
106
apps/web/modules/api/v2/management/auth/api-wrapper.ts
Normal file
106
apps/web/modules/api/v2/management/auth/api-wrapper.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
67
apps/web/modules/api/v2/management/contacts/lib/openapi.ts
Normal file
67
apps/web/modules/api/v2/management/contacts/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
44
apps/web/modules/api/v2/management/lib/api-key.ts
Normal file
44
apps/web/modules/api/v2/management/lib/api-key.ts
Normal 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)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
16
apps/web/modules/api/v2/management/lib/helper.ts
Normal file
16
apps/web/modules/api/v2/management/lib/helper.ts
Normal 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);
|
||||
};
|
||||
22
apps/web/modules/api/v2/management/lib/openapi.ts
Normal file
22
apps/web/modules/api/v2/management/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
43
apps/web/modules/api/v2/management/lib/services.ts
Normal file
43
apps/web/modules/api/v2/management/lib/services.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export const apiKey = "test-api-key";
|
||||
export const environmentId = "h8bfgyetrmvdh5v4cvexogd9";
|
||||
81
apps/web/modules/api/v2/management/lib/tests/api-key.test.ts
Normal file
81
apps/web/modules/api/v2/management/lib/tests/api-key.test.ts
Normal 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" }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
43
apps/web/modules/api/v2/management/lib/tests/helper.test.ts
Normal file
43
apps/web/modules/api/v2/management/lib/tests/helper.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
30
apps/web/modules/api/v2/management/lib/tests/utils.test.ts
Normal file
30
apps/web/modules/api/v2/management/lib/tests/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
3
apps/web/modules/api/v2/management/lib/utils.ts
Normal file
3
apps/web/modules/api/v2/management/lib/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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`,
|
||||
],
|
||||
};
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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.",
|
||||
});
|
||||
67
apps/web/modules/api/v2/management/responses/lib/openapi.ts
Normal file
67
apps/web/modules/api/v2/management/responses/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
184
apps/web/modules/api/v2/management/responses/lib/organization.ts
Normal file
184
apps/web/modules/api/v2/management/responses/lib/organization.ts
Normal 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
|
||||
}
|
||||
)()
|
||||
);
|
||||
153
apps/web/modules/api/v2/management/responses/lib/response.ts
Normal file
153
apps/web/modules/api/v2/management/responses/lib/response.ts
Normal 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 }] });
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
85
apps/web/modules/api/v2/management/responses/lib/utils.ts
Normal file
85
apps/web/modules/api/v2/management/responses/lib/utils.ts
Normal 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;
|
||||
};
|
||||
83
apps/web/modules/api/v2/management/responses/route.ts
Normal file
83
apps/web/modules/api/v2/management/responses/route.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
67
apps/web/modules/api/v2/management/surveys/lib/openapi.ts
Normal file
67
apps/web/modules/api/v2/management/surveys/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
81
apps/web/modules/api/v2/management/surveys/types/surveys.ts
Normal file
81
apps/web/modules/api/v2/management/surveys/types/surveys.ts
Normal 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>;
|
||||
83
apps/web/modules/api/v2/openapi-document.ts
Normal file
83
apps/web/modules/api/v2/openapi-document.ts
Normal 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));
|
||||
11
apps/web/modules/api/v2/types/api-error.ts
Normal file
11
apps/web/modules/api/v2/types/api-error.ts
Normal 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;
|
||||
};
|
||||
13
apps/web/modules/api/v2/types/api-success.ts
Normal file
13
apps/web/modules/api/v2/types/api-success.ts
Normal 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>;
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/web/playwright/api/constants.ts
Normal file
2
apps/web/playwright/api/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const RESPONSES_API_URL = `/api/v2/management/responses`;
|
||||
export const SURVEYS_API_URL = `/api/v1/management/surveys`;
|
||||
447
apps/web/playwright/api/management/responses.spec.ts
Normal file
447
apps/web/playwright/api/management/responses.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user