diff --git a/.env.example b/.env.example index 12c75fedd5..6b5b2582f2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3517881c48..53d31bb810 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 07d6468477..41612efcac 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -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 ] } diff --git a/apps/web/app/(redirects)/organizations/[organizationId]/route.ts b/apps/web/app/(redirects)/organizations/[organizationId]/route.ts index 6d9620b42c..9f81f7cd2d 100644 --- a/apps/web/app/(redirects)/organizations/[organizationId]/route.ts +++ b/apps/web/app/(redirects)/organizations/[organizationId]/route.ts @@ -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); diff --git a/apps/web/app/(redirects)/projects/[projectId]/route.ts b/apps/web/app/(redirects)/projects/[projectId]/route.ts index 4c28c35fff..ba4f230426 100644 --- a/apps/web/app/(redirects)/projects/[projectId]/route.ts +++ b/apps/web/app/(redirects)/projects/[projectId]/route.ts @@ -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); diff --git a/apps/web/app/api/v1/auth.ts b/apps/web/app/api/v1/auth.ts index 0fe9090188..eeb67bca90 100644 --- a/apps/web/app/api/v1/auth.ts +++ b/apps/web/app/api/v1/auth.ts @@ -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 => { 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; } diff --git a/apps/web/app/api/v1/lib/api-key.ts b/apps/web/app/api/v1/lib/api-key.ts index c90e80d216..62a69b315c 100644 --- a/apps/web/app/api/v1/lib/api-key.ts +++ b/apps/web/app/api/v1/lib/api-key.ts @@ -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 => { 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)], } diff --git a/apps/web/app/api/v1/management/me/lib/utils.ts b/apps/web/app/api/v1/management/me/lib/utils.ts new file mode 100644 index 0000000000..f2aa079838 --- /dev/null +++ b/apps/web/app/api/v1/management/me/lib/utils.ts @@ -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; +}; diff --git a/apps/web/app/api/v1/management/me/route.ts b/apps/web/app/api/v1/management/me/route.ts index a12c337a22..e43eff3bed 100644 --- a/apps/web/app/api/v1/management/me/route.ts +++ b/apps/web/app/api/v1/management/me/route.ts @@ -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"; diff --git a/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts b/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts new file mode 100644 index 0000000000..f2943a511c --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts @@ -0,0 +1,6 @@ +import { + OPTIONS, + PUT, +} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route"; + +export { OPTIONS, PUT }; diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/route.ts b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts new file mode 100644 index 0000000000..10df87540a --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/displays/route"; + +export { OPTIONS, POST }; diff --git a/apps/web/app/api/v2/client/[environmentId]/environment/route.ts b/apps/web/app/api/v2/client/[environmentId]/environment/route.ts new file mode 100644 index 0000000000..2a486943cd --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/environment/route.ts @@ -0,0 +1,3 @@ +import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route"; + +export { OPTIONS, GET }; diff --git a/apps/web/app/api/v2/client/[environmentId]/identify/contacts/[userId]/route.ts b/apps/web/app/api/v2/client/[environmentId]/identify/contacts/[userId]/route.ts new file mode 100644 index 0000000000..b81a65e3b3 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/identify/contacts/[userId]/route.ts @@ -0,0 +1,6 @@ +import { + GET, + OPTIONS, +} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route"; + +export { GET, OPTIONS }; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/[responseId]/route.ts b/apps/web/app/api/v2/client/[environmentId]/responses/[responseId]/route.ts new file mode 100644 index 0000000000..2e177bd163 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/[responseId]/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route"; + +export { OPTIONS, PUT }; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts new file mode 100644 index 0000000000..2fb4ec337c --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts @@ -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)], + } + )() +); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts new file mode 100644 index 0000000000..2f1eee4c73 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts @@ -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 => { + 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; + } +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/route.ts b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts new file mode 100644 index 0000000000..b695171b74 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts @@ -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 => { + return responses.successResponse({}, true); +}; + +export const POST = async (request: Request, context: Context): Promise => { + 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); +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/types/response.ts b/apps/web/app/api/v2/client/[environmentId]/responses/types/response.ts new file mode 100644 index 0000000000..d86a27ed34 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/types/response.ts @@ -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; diff --git a/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts new file mode 100644 index 0000000000..cb0a14158f --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route"; + +export { OPTIONS, POST }; diff --git a/apps/web/app/api/v2/client/[environmentId]/storage/route.ts b/apps/web/app/api/v2/client/[environmentId]/storage/route.ts new file mode 100644 index 0000000000..58d117cceb --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/storage/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route"; + +export { OPTIONS, POST }; diff --git a/apps/web/app/api/v2/client/[environmentId]/user/route.ts b/apps/web/app/api/v2/client/[environmentId]/user/route.ts new file mode 100644 index 0000000000..0198ac1f99 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/user/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route"; + +export { POST, OPTIONS }; diff --git a/apps/web/app/api/v2/management/responses/[responseId]/route.ts b/apps/web/app/api/v2/management/responses/[responseId]/route.ts new file mode 100644 index 0000000000..40f1cd7bbc --- /dev/null +++ b/apps/web/app/api/v2/management/responses/[responseId]/route.ts @@ -0,0 +1,3 @@ +import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route"; + +export { GET, PUT, DELETE }; diff --git a/apps/web/app/api/v2/management/responses/route.ts b/apps/web/app/api/v2/management/responses/route.ts new file mode 100644 index 0000000000..14891ecfd5 --- /dev/null +++ b/apps/web/app/api/v2/management/responses/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/modules/api/v2/management/responses/route"; + +export { GET, POST }; diff --git a/apps/web/app/lib/api/apiHelper.ts b/apps/web/app/lib/api/apiHelper.ts deleted file mode 100644 index 3c43026d8b..0000000000 --- a/apps/web/app/lib/api/apiHelper.ts +++ /dev/null @@ -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; -}; diff --git a/apps/web/app/lib/api/response.ts b/apps/web/app/lib/api/response.ts index ac4b9c3f93..91714161a2 100644 --- a/apps/web/app/lib/api/response.ts +++ b/apps/web/app/lib/api/response.ts @@ -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, diff --git a/apps/web/app/middleware/endpoint-validator.ts b/apps/web/app/middleware/endpoint-validator.ts index 6462ac728d..ef079a6ba7 100644 --- a/apps/web/app/middleware/endpoint-validator.ts +++ b/apps/web/app/middleware/endpoint-validator.ts @@ -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); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index f951670742..080beca153 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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 => { + 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*", ], }; diff --git a/apps/web/modules/api/v2/lib/rate-limit.ts b/apps/web/modules/api/v2/lib/rate-limit.ts new file mode 100644 index 0000000000..2747b447b6 --- /dev/null +++ b/apps/web/modules/api/v2/lib/rate-limit.ts @@ -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> => { + const response = await rateLimiter()({ identifier, opts }); + const { success } = response; + + if (!success) { + return err({ + type: "too_many_requests", + }); + } + return okVoid(); +}; diff --git a/apps/web/modules/api/v2/lib/response.ts b/apps/web/modules/api/v2/lib/response.ts new file mode 100644 index 0000000000..7eeeea0162 --- /dev/null +++ b/apps/web/modules/api/v2/lib/response.ts @@ -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 = ApiSuccessResponse | 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; + 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, +}; diff --git a/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts b/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts new file mode 100644 index 0000000000..7048ee1aa1 --- /dev/null +++ b/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts @@ -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; + + 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" }); + } + }); +}); diff --git a/apps/web/modules/api/v2/lib/tests/response.test.ts b/apps/web/modules/api/v2/lib/tests/response.test.ts new file mode 100644 index 0000000000..d370bc08ce --- /dev/null +++ b/apps/web/modules/api/v2/lib/tests/response.test.ts @@ -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("*"); + }); + }); +}); diff --git a/apps/web/modules/api/v2/lib/tests/utils.test.ts b/apps/web/modules/api/v2/lib/tests/utils.test.ts new file mode 100644 index 0000000000..ebcb82a9b8 --- /dev/null +++ b/apps/web/modules/api/v2/lib/tests/utils.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/web/modules/api/v2/lib/utils.ts b/apps/web/modules/api/v2/lib/utils.ts new file mode 100644 index 0000000000..80f60e06a6 --- /dev/null +++ b/apps/web/modules/api/v2/lib/utils.ts @@ -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)}` + ); +}; diff --git a/apps/web/modules/api/v2/management/auth/api-wrapper.ts b/apps/web/modules/api/v2/management/auth/api-wrapper.ts new file mode 100644 index 0000000000..16862ce1b2 --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/api-wrapper.ts @@ -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> = ({ + authentication, + parsedInput, + request, +}: { + authentication: TAuthenticationApiKey; + parsedInput: TInput; + request: Request; +}) => Promise; + +export type ExtendedSchemas = { + body?: z.ZodObject; + query?: z.ZodObject; + params?: z.ZodObject; +}; + +// Define a type that returns separate keys for each input type. +export type ParsedSchemas = { + body?: S extends { body: z.ZodObject } ? z.infer : undefined; + query?: S extends { query: z.ZodObject } ? z.infer : undefined; + params?: S extends { params: z.ZodObject } ? z.infer : undefined; +}; + +export const apiWrapper = async ({ + request, + schemas, + externalParams, + rateLimit = true, + handler, +}: { + request: Request; + schemas?: S; + externalParams?: Promise>; + rateLimit?: boolean; + handler: HandlerFn>; +}): Promise => { + try { + const authentication = await authenticateRequest(request); + if (!authentication.ok) throw authentication.error; + + let parsedInput: ParsedSchemas = {} as ParsedSchemas; + + 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["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["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["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); + } +}; diff --git a/apps/web/modules/api/v2/management/auth/authenticate-request.ts b/apps/web/modules/api/v2/management/auth/authenticate-request.ts new file mode 100644 index 0000000000..7e6a1cacde --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/authenticate-request.ts @@ -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> => { + 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", + }); +}; diff --git a/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts b/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts new file mode 100644 index 0000000000..9971582f32 --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts @@ -0,0 +1,32 @@ +import { logApiRequest } from "@/modules/api/v2/lib/utils"; +import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper"; + +export const authenticatedApiClient = async ({ + request, + schemas, + externalParams, + rateLimit = true, + handler, +}: { + request: Request; + schemas?: S; + externalParams?: Promise>; + rateLimit?: boolean; + handler: HandlerFn>; +}): Promise => { + 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; +}; diff --git a/apps/web/modules/api/v2/management/auth/check-authorization.ts b/apps/web/modules/api/v2/management/auth/check-authorization.ts new file mode 100644 index 0000000000..dcfa4bb2fc --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/check-authorization.ts @@ -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 => { + if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) { + return err({ + type: "unauthorized", + }); + } + return okVoid(); +}; diff --git a/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts b/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts new file mode 100644 index 0000000000..ac89f211c4 --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts @@ -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(); + }); +}); diff --git a/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts b/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts new file mode 100644 index 0000000000..aef4ea4982 --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts @@ -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" }); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/auth/tests/authenticated-api-client.test.ts b/apps/web/modules/api/v2/management/auth/tests/authenticated-api-client.test.ts new file mode 100644 index 0000000000..77fc37a951 --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/tests/authenticated-api-client.test.ts @@ -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(); + }); +}); diff --git a/apps/web/modules/api/v2/management/auth/tests/check-authorization.test.ts b/apps/web/modules/api/v2/management/auth/tests/check-authorization.test.ts new file mode 100644 index 0000000000..2afe725b10 --- /dev/null +++ b/apps/web/modules/api/v2/management/auth/tests/check-authorization.test.ts @@ -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" }); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts new file mode 100644 index 0000000000..e16ce064e6 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts @@ -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, + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts new file mode 100644 index 0000000000..e3bcf0767f --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts new file mode 100644 index 0000000000..29d9619e90 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts @@ -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; diff --git a/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts new file mode 100644 index 0000000000..40ae2a16e4 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts @@ -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, + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts new file mode 100644 index 0000000000..f2f5bfc92b --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts b/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts new file mode 100644 index 0000000000..c3f3ca4fe8 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts @@ -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; diff --git a/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts new file mode 100644 index 0000000000..481f37d53f --- /dev/null +++ b/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts @@ -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, + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/management/contacts/lib/openapi.ts b/apps/web/modules/api/v2/management/contacts/lib/openapi.ts new file mode 100644 index 0000000000..e2d5686ff9 --- /dev/null +++ b/apps/web/modules/api/v2/management/contacts/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/contacts/types/contacts.ts b/apps/web/modules/api/v2/management/contacts/types/contacts.ts new file mode 100644 index 0000000000..2cddc7e865 --- /dev/null +++ b/apps/web/modules/api/v2/management/contacts/types/contacts.ts @@ -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; diff --git a/apps/web/modules/api/v2/management/lib/api-key.ts b/apps/web/modules/api/v2/management/lib/api-key.ts new file mode 100644 index 0000000000..eb894594b2 --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/api-key.ts @@ -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> => { + 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)], + } + )(); +}); diff --git a/apps/web/modules/api/v2/management/lib/helper.ts b/apps/web/modules/api/v2/management/lib/helper.ts new file mode 100644 index 0000000000..0b5d07e406 --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/helper.ts @@ -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> => { + const result = await fetchEnvironmentId(id, isResponseId); + + if (!result.ok) { + return result; + } + + return ok(result.data.environmentId); +}; diff --git a/apps/web/modules/api/v2/management/lib/openapi.ts b/apps/web/modules/api/v2/management/lib/openapi.ts new file mode 100644 index 0000000000..f268bb2516 --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/lib/services.ts b/apps/web/modules/api/v2/management/lib/services.ts new file mode 100644 index 0000000000..1d1a769104 --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/services.ts @@ -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> => { + 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)], + } + )() +); diff --git a/apps/web/modules/api/v2/management/lib/tests/__mocks__/api-key.mock.ts b/apps/web/modules/api/v2/management/lib/tests/__mocks__/api-key.mock.ts new file mode 100644 index 0000000000..16c4a78c6e --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/tests/__mocks__/api-key.mock.ts @@ -0,0 +1,2 @@ +export const apiKey = "test-api-key"; +export const environmentId = "h8bfgyetrmvdh5v4cvexogd9"; diff --git a/apps/web/modules/api/v2/management/lib/tests/api-key.test.ts b/apps/web/modules/api/v2/management/lib/tests/api-key.test.ts new file mode 100644 index 0000000000..2b316807de --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/tests/api-key.test.ts @@ -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" }]); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts new file mode 100644 index 0000000000..5b76f2360b --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts @@ -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"); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/tests/services.test.ts b/apps/web/modules/api/v2/management/lib/tests/services.test.ts new file mode 100644 index 0000000000..9e22295f7a --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/tests/services.test.ts @@ -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"); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts new file mode 100644 index 0000000000..d4f4332c1b --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts @@ -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); + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts new file mode 100644 index 0000000000..0d8195da8a --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/utils.ts @@ -0,0 +1,3 @@ +import { createHash } from "crypto"; + +export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex"); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts new file mode 100644 index 0000000000..a957d09e3b --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts @@ -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> => { + 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 }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts new file mode 100644 index 0000000000..6bbf881b77 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts @@ -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, + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts new file mode 100644 index 0000000000..fd41694772 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts @@ -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> => { + 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> => { + 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 +): Promise> => { + 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 }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/survey.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/survey.ts new file mode 100644 index 0000000000..b0dd4b2be9 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/survey.ts @@ -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, 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)], + } + )() +); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/display.mock.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/display.mock.ts new file mode 100644 index 0000000000..4c5197dd03 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/display.mock.ts @@ -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"; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/response.mock.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/response.mock.ts new file mode 100644 index 0000000000..4b9c2ebcaa --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/response.mock.ts @@ -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 = { + 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 = { + questions: [ + { + id: "ggaw04zw7gx7uxodk5da7if8", + type: TSurveyQuestionTypeEnum.FileUpload, + headline: { en: "Question 1" }, + required: true, + allowMultipleFiles: true, + }, + ], + environmentId: "z5t8e52wy6xvi61ubebs2e4i", +}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/survey.mock.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/survey.mock.ts new file mode 100644 index 0000000000..ce8263b9d6 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/survey.mock.ts @@ -0,0 +1,18 @@ +import { Survey } from "@prisma/client"; +import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; + +export const survey: Pick = { + id: "rp2di001zicbm3mk8je1ue9u", + questions: [ + { + id: "i0e9y9ya4pl9iyrurlrak3yq", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question Text", de: "Fragetext" }, + required: false, + inputType: "text", + charLimit: { + enabled: false, + }, + }, + ], +}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts new file mode 100644 index 0000000000..bf1d7c53e7 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts @@ -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`, + ], +}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/display.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/display.test.ts new file mode 100644 index 0000000000..11e8958967 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/display.test.ts @@ -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" }], + }); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts new file mode 100644 index 0000000000..b4a5717337 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts @@ -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" }], + }); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/survey.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/survey.test.ts new file mode 100644 index 0000000000..cabc47a49d --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/survey.test.ts @@ -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" }], + }); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts new file mode 100644 index 0000000000..a5e3ad6488 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts @@ -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()); + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts new file mode 100644 index 0000000000..005c9de21e --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts @@ -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> => { + 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(); +}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts new file mode 100644 index 0000000000..08a01513aa --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -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 }); + }, + }); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/types/responses.ts b/apps/web/modules/api/v2/management/responses/[responseId]/types/responses.ts new file mode 100644 index 0000000000..68118bdbe2 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/[responseId]/types/responses.ts @@ -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.", +}); diff --git a/apps/web/modules/api/v2/management/responses/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/lib/openapi.ts new file mode 100644 index 0000000000..f562b1c3c6 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/responses/lib/organization.ts b/apps/web/modules/api/v2/management/responses/lib/organization.ts new file mode 100644 index 0000000000..9ca2a06cef --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/organization.ts @@ -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> => { + 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, 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> => { + 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> => { + 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 + } + )() +); diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts new file mode 100644 index 0000000000..0c1ccf841a --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -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> => { + 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, 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 }] }); + } +}; diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/__mocks__/organization.mock.ts b/apps/web/modules/api/v2/management/responses/lib/tests/__mocks__/organization.mock.ts new file mode 100644 index 0000000000..841729e60f --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/tests/__mocks__/organization.mock.ts @@ -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", +]; diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/__mocks__/response.mock.ts b/apps/web/modules/api/v2/management/responses/lib/tests/__mocks__/response.mock.ts new file mode 100644 index 0000000000..8f502d87db --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/tests/__mocks__/response.mock.ts @@ -0,0 +1,96 @@ +import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses"; +import { Organization, Response } from "@prisma/client"; + +export const responseInput: Omit = { + 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 = { + 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 = { + 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 = { + 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", +}; diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts new file mode 100644 index 0000000000..3dc84295d0 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts @@ -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" }], + }); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts new file mode 100644 index 0000000000..d225af34a1 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts @@ -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" }], + }); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts new file mode 100644 index 0000000000..088c955350 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts @@ -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); + }); +}); diff --git a/apps/web/modules/api/v2/management/responses/lib/utils.ts b/apps/web/modules/api/v2/management/responses/lib/utils.ts new file mode 100644 index 0000000000..536022d508 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/lib/utils.ts @@ -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; +}; diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts new file mode 100644 index 0000000000..afa4faff30 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -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 }); + }, + }); diff --git a/apps/web/modules/api/v2/management/responses/types/responses.ts b/apps/web/modules/api/v2/management/responses/types/responses.ts new file mode 100644 index 0000000000..b2161aa953 --- /dev/null +++ b/apps/web/modules/api/v2/management/responses/types/responses.ts @@ -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; + +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; diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/lib/openapi.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/lib/openapi.ts new file mode 100644 index 0000000000..7b124889ca --- /dev/null +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/lib/openapi.ts @@ -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, + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/types/survey.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/types/survey.ts new file mode 100644 index 0000000000..d4fc5ecf8e --- /dev/null +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/types/survey.ts @@ -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", + }, + }); diff --git a/apps/web/modules/api/v2/management/surveys/lib/openapi.ts b/apps/web/modules/api/v2/management/surveys/lib/openapi.ts new file mode 100644 index 0000000000..ad86ff9c39 --- /dev/null +++ b/apps/web/modules/api/v2/management/surveys/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/surveys/types/surveys.ts b/apps/web/modules/api/v2/management/surveys/types/surveys.ts new file mode 100644 index 0000000000..0bac188ac6 --- /dev/null +++ b/apps/web/modules/api/v2/management/surveys/types/surveys.ts @@ -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; diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts new file mode 100644 index 0000000000..2c5b179450 --- /dev/null +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -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)); diff --git a/apps/web/modules/api/v2/types/api-error.ts b/apps/web/modules/api/v2/types/api-error.ts new file mode 100644 index 0000000000..06e69c3f49 --- /dev/null +++ b/apps/web/modules/api/v2/types/api-error.ts @@ -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; + }; diff --git a/apps/web/modules/api/v2/types/api-success.ts b/apps/web/modules/api/v2/types/api-success.ts new file mode 100644 index 0000000000..9ff5f129b4 --- /dev/null +++ b/apps/web/modules/api/v2/types/api-success.ts @@ -0,0 +1,13 @@ +export interface ApiResponse { + data: T; +} + +export interface ApiResponseWithMeta extends ApiResponse { + meta?: { + total?: number; + limit?: number; + offset?: number; + }; +} + +export type ApiSuccessResponse = ApiResponse | ApiResponseWithMeta; diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/lib/personState.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/lib/personState.ts index caaa3d2d36..dc0d7e6729 100644 --- a/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/lib/personState.ts +++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/lib/personState.ts @@ -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: diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/user-state.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/user-state.ts index df7b5e8c5c..911db2af70 100644 --- a/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/user-state.ts +++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/user-state.ts @@ -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: diff --git a/apps/web/package.json b/apps/web/package.json index b727741685..e94a3433ca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/playwright/api/constants.ts b/apps/web/playwright/api/constants.ts new file mode 100644 index 0000000000..6f59359be4 --- /dev/null +++ b/apps/web/playwright/api/constants.ts @@ -0,0 +1,2 @@ +export const RESPONSES_API_URL = `/api/v2/management/responses`; +export const SURVEYS_API_URL = `/api/v1/management/surveys`; diff --git a/apps/web/playwright/api/management/responses.spec.ts b/apps/web/playwright/api/management/responses.spec.ts new file mode 100644 index 0000000000..ff4fc062ec --- /dev/null +++ b/apps/web/playwright/api/management/responses.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/web/playwright/api/management/survey.spec.ts b/apps/web/playwright/api/management/survey.spec.ts index 894559f7b2..985950dfba 100644 --- a/apps/web/playwright/api/management/survey.spec.ts +++ b/apps/web/playwright/api/management/survey.spec.ts @@ -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, diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 9048fb4b47..87457792ff 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -39,7 +39,18 @@ export const createUserFixture = ( export type UserFixture = ReturnType; -export const createUsersFixture = (page: Page, workerInfo: TestInfo) => { +export type UsersFixture = { + create: (params?: { + name?: string; + email?: string; + organizationName?: string; + projectName?: string; + withoutProject?: boolean; + }) => Promise; + get: () => UserFixture[]; +}; + +export const createUsersFixture = (page: Page, workerInfo: TestInfo): UsersFixture => { const store: { users: UserFixture[] } = { users: [], }; diff --git a/apps/web/playwright/lib/utils.ts b/apps/web/playwright/lib/utils.ts new file mode 100644 index 0000000000..fe8999b413 --- /dev/null +++ b/apps/web/playwright/lib/utils.ts @@ -0,0 +1,27 @@ +import { Page } from "@playwright/test"; +import { UsersFixture } from "../fixtures/users"; + +export async function loginAndGetApiKey(page: Page, users: UsersFixture) { + const user = await users.create(); + await user.login(); + + await page.waitForURL(/\/environments\/[^/]+\/surveys/); + + const 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(); + + const apiKey = await page.evaluate("navigator.clipboard.readText()"); + + return { environmentId, apiKey }; +} diff --git a/apps/web/scripts/merge-client-endpoints.ts b/apps/web/scripts/merge-client-endpoints.ts new file mode 100644 index 0000000000..e1bff09938 --- /dev/null +++ b/apps/web/scripts/merge-client-endpoints.ts @@ -0,0 +1,321 @@ +import * as fs from "fs"; +import * as yaml from "yaml"; + +// Define the v1 (now v2) client endpoints to be merged +const v1ClientEndpoints = { + "/responses/{responseId}": { + put: { + description: + "Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.", + parameters: [ + { + in: "path", + name: "responseId", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + example: { + data: { tcgls0063n8ri7dtrbnepcmz: "Who? Who? Who?" }, + finished: true, + }, + type: "object", + }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + example: { data: {} }, + schema: { type: "object" }, + }, + }, + description: "OK", + }, + "404": { + content: { + "application/json": { + example: { + code: "not_found", + details: { resource_type: "Response" }, + message: "Response not found", + }, + schema: { type: "object" }, + }, + }, + description: "Not Found", + }, + }, + summary: "Update Response", + tags: ["Client API > Response"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, + "/{environmentId}/responses": { + post: { + description: + "Create a response for a survey and its fields with the user's responses. The userId & meta here is optional", + requestBody: { + content: { + "application/json": { + schema: { example: { surveyId: "survey123", responses: {} }, type: "object" }, + }, + }, + }, + responses: { + "201": { + content: { + "application/json": { + example: { responseId: "response123" }, + schema: { type: "object" }, + }, + }, + description: "Created", + }, + }, + summary: "Create Response", + tags: ["Client API > Response"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, + "/{environmentId}/contacts/{userId}/attributes": { + put: { + description: + "Update a contact's attributes in Formbricks to keep them in sync with your app or when you want to set a custom attribute in Formbricks.", + parameters: [ + { in: "path", name: "environmentId", required: true, schema: { type: "string" } }, + { in: "path", name: "userId", required: true, schema: { type: "string" } }, + ], + requestBody: { + content: { + "application/json": { + schema: { example: { attributes: {} }, type: "object" }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + examples: { "example-0": {}, "example-1": {} }, + schema: { type: "object" }, + }, + }, + description: "OK", + }, + "500": { + content: { + "application/json": { + example: { + code: "internal_server_error", + details: {}, + message: "Unable to complete request: Expected", + }, + schema: { type: "object" }, + }, + }, + description: "Internal Server Error", + }, + }, + summary: "Update Contact (Attributes)", + tags: ["Client API > Contacts"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, + "/{environmentId}/identify/contacts/{userId}": { + get: { + description: + "Retrieves a contact's state including their segments, displays, responses and other tracking information. If the contact doesn't exist, it will be created.", + parameters: [ + { in: "path", name: "environmentId", required: true, schema: { type: "string" } }, + { in: "path", name: "userId", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + content: { + "application/json": { + example: { userId: "user123", state: "active" }, + schema: { type: "object" }, + }, + }, + description: "OK", + }, + }, + summary: "Get Contact State", + tags: ["Client API > Contacts"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, + "/{environmentId}/displays": { + post: { + description: + "Create a new display for a valid survey ID. If a userId is passed, the display is linked to the user.", + requestBody: { + content: { + "application/json": { + schema: { example: { surveyId: "survey123", userId: "user123" }, type: "object" }, + }, + }, + }, + responses: { + "201": { + content: { + "application/json": { + example: { displayId: "display123" }, + schema: { type: "object" }, + }, + }, + description: "Created", + }, + }, + summary: "Create Display", + tags: ["Client API > Display"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, + "/{environmentId}/displays/{displayId}": { + put: { + description: + "Update a Display for a user. A use case can be when a user submits a response & you want to link it to an existing display.", + parameters: [{ in: "path", name: "displayId", required: true, schema: { type: "string" } }], + requestBody: { + content: { + "application/json": { + schema: { example: { responseId: "response123" }, type: "object" }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + example: { displayId: "display123" }, + schema: { type: "object" }, + }, + }, + description: "OK", + }, + }, + summary: "Update Display", + tags: ["Client API > Display"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, + "/{environmentId}/environment": { + get: { + description: "Retrieves the environment state to be used in Formbricks SDKs", + responses: { + "200": { + content: { + "application/json": { + example: { environmentId: "env123", state: "active" }, + schema: { type: "object" }, + }, + }, + description: "OK", + }, + }, + summary: "Get Environment State", + tags: ["Client API > Environment"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, + "/{environmentId}/user": { + post: { + description: + "Endpoint for creating or identifying a user within the specified environment. If the user already exists, this will identify them and potentially update user attributes. If they don't exist, it will create a new user.", + requestBody: { + content: { + "application/json": { + schema: { example: { userId: "user123", attributes: {} }, type: "object" }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + example: { userId: "user123", state: "identified" }, + schema: { type: "object" }, + }, + }, + description: "OK", + }, + }, + summary: "Create or Identify User", + tags: ["Client API > User"], + servers: [ + { + url: "https://app.formbricks.com/api/v2/client", + description: "Formbricks Client", + }, + ], + }, + }, +}; + +// Read the generated openapi.yml file +const openapiFilePath = "../../docs/api-v2-reference/openapi.yml"; +const openapiContent = fs.readFileSync(openapiFilePath, "utf8"); + +// Parse the YAML content +const openapiDoc = yaml.parse(openapiContent); + +// Merge the v1 client endpoints into the parsed content +openapiDoc.paths = { + ...v1ClientEndpoints, + ...openapiDoc.paths, +}; + +// Write the updated content back to the openapi.yml file +const updatedOpenapiContent = yaml.stringify(openapiDoc); + +// Write the updated content back to the openapi.yml file +try { + fs.writeFileSync(openapiFilePath, updatedOpenapiContent); + console.log("Merged v1 client endpoints into the generated v2 documentation."); +} catch (error) { + console.error("Error writing to OpenAPI file:", error); + process.exit(1); +} diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index e829c79c83..dd1ba19bdd 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -12,6 +12,21 @@ export default defineConfig({ provider: 'v8', // Use V8 as the coverage provider reporter: ['text', 'html', 'lcov'], // Generate text summary and HTML reports reportsDirectory: './coverage', // Output coverage reports to the coverage/ directory + include: [ + 'modules/api/v2/**/*.ts', + 'modules/auth/lib/**/*.ts', + 'modules/signup/lib/**/*.ts', + ], + exclude: [ + '**/.next/**', + '**/*.test.*', + '**/*.spec.*', + '**/constants.ts', // Exclude constants files + '**/route.ts', // Exclude route files + '**/openapi.ts', // Exclude openapi configuration files + '**/openapi-document.ts', // Exclude openapi document files + 'modules/**/types/**', // Exclude types + ], }, }, plugins: [tsconfigPaths()], diff --git a/docs/api-v2-reference/openapi.yml b/docs/api-v2-reference/openapi.yml index 14c0913881..c56a37b943 100644 --- a/docs/api-v2-reference/openapi.yml +++ b/docs/api-v2-reference/openapi.yml @@ -1,28 +1,292 @@ openapi: 3.1.0 info: title: Formbricks API - description: Manage Formbricks ressources programmatically. + description: Manage Formbricks resources programmatically. version: 2.0.0 servers: - - url: https://app.formbricks.com/api + - url: https://app.formbricks.com/api/v2/management description: Formbricks Cloud tags: - - name: Responses + - name: Management API > Responses description: Operations for managing responses. - - name: Contacts + - name: Management API > Contacts description: Operations for managing contacts. - - name: Contact Attributes + - name: Management API > Contact Attributes description: Operations for managing contact attributes. - - name: Contact Attributes Keys + - name: Management API > Contact Attributes Keys description: Operations for managing contact attributes keys. + - name: Management API > Surveys + description: Operations for managing surveys. +security: + - apiKeyAuth: [] paths: + /responses/{responseId}: + put: + description: Update an existing response for example when you want to mark a + response as finished or you want to change an existing response's value. + parameters: + - in: path + name: responseId + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + example: + data: + tcgls0063n8ri7dtrbnepcmz: Who? Who? Who? + finished: true + type: object + responses: + "200": + content: + application/json: + example: + data: {} + schema: + type: object + description: OK + "404": + content: + application/json: + example: + code: not_found + details: + resource_type: Response + message: Response not found + schema: + type: object + description: Not Found + summary: Update Response + tags: + - Client API > Response + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client + /{environmentId}/responses: + post: + description: Create a response for a survey and its fields with the user's + responses. The userId & meta here is optional + requestBody: + content: + application/json: + schema: + example: + surveyId: survey123 + responses: {} + type: object + responses: + "201": + content: + application/json: + example: + responseId: response123 + schema: + type: object + description: Created + summary: Create Response + tags: + - Client API > Response + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client + /{environmentId}/contacts/{userId}/attributes: + put: + description: Update a contact's attributes in Formbricks to keep them in sync + with your app or when you want to set a custom attribute in Formbricks. + parameters: + - in: path + name: environmentId + required: true + schema: + type: string + - in: path + name: userId + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + example: + attributes: {} + type: object + responses: + "200": + content: + application/json: + examples: + example-0: {} + example-1: {} + schema: + type: object + description: OK + "500": + content: + application/json: + example: + code: internal_server_error + details: {} + message: "Unable to complete request: Expected" + schema: + type: object + description: Internal Server Error + summary: Update Contact (Attributes) + tags: + - Client API > Contacts + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client + /{environmentId}/identify/contacts/{userId}: + get: + description: Retrieves a contact's state including their segments, displays, + responses and other tracking information. If the contact doesn't exist, + it will be created. + parameters: + - in: path + name: environmentId + required: true + schema: + type: string + - in: path + name: userId + required: true + schema: + type: string + responses: + "200": + content: + application/json: + example: + userId: user123 + state: active + schema: + type: object + description: OK + summary: Get Contact State + tags: + - Client API > Contacts + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client + /{environmentId}/displays: + post: + description: Create a new display for a valid survey ID. If a userId is passed, + the display is linked to the user. + requestBody: + content: + application/json: + schema: + example: + surveyId: survey123 + userId: user123 + type: object + responses: + "201": + content: + application/json: + example: + displayId: display123 + schema: + type: object + description: Created + summary: Create Display + tags: + - Client API > Display + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client + /{environmentId}/displays/{displayId}: + put: + description: Update a Display for a user. A use case can be when a user submits + a response & you want to link it to an existing display. + parameters: + - in: path + name: displayId + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + example: + responseId: response123 + type: object + responses: + "200": + content: + application/json: + example: + displayId: display123 + schema: + type: object + description: OK + summary: Update Display + tags: + - Client API > Display + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client + /{environmentId}/environment: + get: + description: Retrieves the environment state to be used in Formbricks SDKs + responses: + "200": + content: + application/json: + example: + environmentId: env123 + state: active + schema: + type: object + description: OK + summary: Get Environment State + tags: + - Client API > Environment + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client + /{environmentId}/user: + post: + description: Endpoint for creating or identifying a user within the specified + environment. If the user already exists, this will identify them and + potentially update user attributes. If they don't exist, it will create + a new user. + requestBody: + content: + application/json: + schema: + example: + userId: user123 + attributes: {} + type: object + responses: + "200": + content: + application/json: + example: + userId: user123 + state: identified + schema: + type: object + description: OK + summary: Create or Identify User + tags: + - Client API > User + servers: + - url: https://app.formbricks.com/api/v2/client + description: Formbricks Client /responses: get: operationId: getResponses summary: Get responses description: Gets responses from the database. tags: - - responses + - Management API > Responses parameters: - in: query name: limit @@ -87,7 +351,7 @@ paths: summary: Create a response description: Creates a response in the database. tags: - - responses + - Management API > Responses requestBody: required: true description: The response to create @@ -108,7 +372,7 @@ paths: summary: Get a response description: Gets a response from the database. tags: - - responses + - Management API > Responses parameters: - in: path name: id @@ -128,7 +392,7 @@ paths: summary: Update a response description: Updates a response in the database. tags: - - responses + - Management API > Responses parameters: - in: path name: id @@ -231,7 +495,7 @@ paths: summary: Delete a response description: Deletes a response from the database. tags: - - responses + - Management API > Responses parameters: - in: path name: id @@ -252,7 +516,7 @@ paths: summary: Get contacts description: Gets contacts from the database. tags: - - contacts + - Management API > Contacts parameters: - in: query name: limit @@ -305,7 +569,7 @@ paths: summary: Create a contact description: Creates a contact in the database. tags: - - contacts + - Management API > Contacts requestBody: required: true description: The contact to create @@ -326,7 +590,7 @@ paths: summary: Get a contact description: Gets a contact from the database. tags: - - contacts + - Management API > Contacts parameters: - in: path name: contactId @@ -345,7 +609,7 @@ paths: summary: Update a contact description: Updates a contact in the database. tags: - - contacts + - Management API > Contacts parameters: - in: path name: contactId @@ -371,7 +635,7 @@ paths: summary: Delete a contact description: Deletes a contact from the database. tags: - - contacts + - Management API > Contacts parameters: - in: path name: contactId @@ -391,7 +655,7 @@ paths: summary: Get contact attributes description: Gets contact attributes from the database. tags: - - contact-attributes + - Management API > Contact Attributes parameters: - in: query name: limit @@ -444,7 +708,7 @@ paths: summary: Create a contact attribute description: Creates a contact attribute in the database. tags: - - contact-attributes + - Management API > Contact Attributes requestBody: required: true description: The contact attribute to create @@ -461,7 +725,7 @@ paths: summary: Get a contact attribute description: Gets a contact attribute from the database. tags: - - contact-attributes + - Management API > Contact Attributes parameters: - in: path name: contactAttributeId @@ -480,7 +744,7 @@ paths: summary: Update a contact attribute description: Updates a contact attribute in the database. tags: - - contact-attributes + - Management API > Contact Attributes parameters: - in: path name: contactAttributeId @@ -506,7 +770,7 @@ paths: summary: Delete a contact attribute description: Deletes a contact attribute from the database. tags: - - contact-attributes + - Management API > Contact Attributes parameters: - in: path name: contactAttributeId @@ -526,7 +790,7 @@ paths: summary: Get contact attribute keys description: Gets contact attribute keys from the database. tags: - - contact-attribute-keys + - Management API > Contact Attribute Keys parameters: - in: query name: limit @@ -579,7 +843,7 @@ paths: summary: Create a contact attribute key description: Creates a contact attribute key in the database. tags: - - contact-attribute-keys + - Management API > Contact Attribute Keys requestBody: required: true description: The contact attribute key to create @@ -596,7 +860,7 @@ paths: summary: Get a contact attribute key description: Gets a contact attribute key from the database. tags: - - contact-attribute-keys + - Management API > Contact Attribute Keys parameters: - in: path name: contactAttributeKeyId @@ -615,7 +879,7 @@ paths: summary: Update a contact attribute key description: Updates a contact attribute key in the database. tags: - - contact-attribute-keys + - Management API > Contact Attribute Keys parameters: - in: path name: contactAttributeKeyId @@ -641,7 +905,7 @@ paths: summary: Delete a contact attribute key description: Deletes a contact attribute key from the database. tags: - - contact-attribute-keys + - Management API > Contact Attribute Keys parameters: - in: path name: contactAttributeId @@ -655,7 +919,172 @@ paths: application/json: schema: $ref: "#/components/schemas/contactAttributeKey" + /surveys: + get: + operationId: getSurveys + summary: Get surveys + description: Gets surveys from the database. + tags: + - Management API > Surveys + parameters: + - in: query + name: limit + schema: + type: number + minimum: 1 + maximum: 100 + default: 10 + - in: query + name: skip + schema: + type: number + minimum: 0 + default: 0 + - in: query + name: sortBy + schema: + type: string + enum: + - createdAt + - updatedAt + default: createdAt + - in: query + name: order + schema: + type: string + enum: + - asc + - desc + default: desc + - in: query + name: startDate + schema: + type: string + - in: query + name: endDate + schema: + type: string + - in: query + name: surveyType + schema: + type: string + enum: + - link + - app + - in: query + name: surveyStatus + schema: + type: string + enum: + - draft + - scheduled + - inProgress + - paused + - completed + responses: + "200": + description: Surveys retrieved successfully. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/survey" + post: + 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: + $ref: "#/components/schemas/surveyInput" + responses: + "201": + description: Survey created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/survey" + /surveys/{id}: + get: + operationId: getSurvey + summary: Get a survey + description: Gets a survey from the database. + tags: + - Management API > Surveys + parameters: + - in: path + name: id + description: The ID of the survey + schema: + $ref: "#/components/schemas/surveyId" + required: true + responses: + "200": + description: Response retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/survey" + put: + operationId: updateSurvey + summary: Update a survey + description: Updates a survey in the database. + tags: + - Management API > Surveys + parameters: + - in: path + name: id + description: The ID of the survey + schema: + $ref: "#/components/schemas/surveyId" + required: true + requestBody: + required: true + description: The survey to update + content: + application/json: + schema: + $ref: "#/components/schemas/surveyInput" + responses: + "200": + description: Response updated successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/survey" + delete: + operationId: deleteSurvey + summary: Delete a survey + description: Deletes a survey from the database. + tags: + - Management API > Surveys + parameters: + - in: path + name: id + description: The ID of the survey + schema: + $ref: "#/components/schemas/surveyId" + required: true + responses: + "200": + description: Response deleted successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/survey" components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: x-api-key + description: Use your Formbricks x-api-key to authenticate. schemas: response: type: object @@ -701,7 +1130,7 @@ components: additionalProperties: type: string description: The data of the response - example: &a1 + example: &a2 question1: answer1 question2: 2 question3: @@ -716,7 +1145,7 @@ components: - type: string - type: number description: The variables of the response - example: &a2 + example: &a3 variable1: answer1 variable2: 2 ttc: @@ -724,7 +1153,7 @@ components: additionalProperties: type: number description: The TTC of the response - example: &a3 + example: &a4 question1: 10 question2: 20 meta: @@ -752,7 +1181,7 @@ components: action: type: string description: The meta data of the response - example: &a4 + example: &a5 source: https://example.com url: https://example.com userAgent: @@ -916,6 +1345,591 @@ components: - description - type - environmentId + survey: + type: object + properties: + id: + type: string + description: The ID of the survey + createdAt: + type: string + description: The date and time the survey was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the survey was last updated + example: 2021-01-01T00:00:00.000Z + name: + type: string + description: The name of the survey + redirectUrl: + type: + - string + - "null" + format: uri + description: The URL to redirect to after the survey is completed + type: + type: string + enum: + - link + - web + - website + - app + description: The type of the survey + status: + type: string + enum: + - draft + - scheduled + - inProgress + - paused + - completed + description: The status of the survey + thankYouMessage: + type: + - string + - "null" + description: The thank you message of the survey + showLanguageSwitch: + type: + - boolean + - "null" + description: Whether to show the language switch + showThankYouMessage: + type: + - boolean + - "null" + description: Whether to show the thank you message + welcomeCard: + type: object + properties: + enabled: + type: boolean + timeToFinish: + type: boolean + showResponseCount: + type: boolean + headline: + type: object + additionalProperties: + type: string + html: + type: object + additionalProperties: + type: string + fileUrl: + type: string + buttonLabel: + type: object + additionalProperties: + type: string + videoUrl: + type: string + required: + - enabled + - timeToFinish + - showResponseCount + description: The welcome card configuration + displayProgressBar: + type: + - boolean + - "null" + description: Whether to display the progress bar + resultShareKey: + type: + - string + - "null" + description: The result share key of the survey + pin: + type: + - string + - "null" + description: The pin of the survey + createdBy: + type: + - string + - "null" + description: The user who created the survey + environmentId: + type: string + description: The environment ID of the survey + endings: + type: array + items: + anyOf: + - type: object + properties: + id: + type: string + type: + type: string + const: endScreen + headline: + type: object + additionalProperties: + type: string + subheader: + type: object + additionalProperties: + type: string + buttonLabel: + type: object + additionalProperties: + type: string + buttonLink: + type: string + format: uri + imageUrl: + type: string + videoUrl: + type: string + required: + - id + - type + - type: object + properties: + id: + type: string + type: + type: string + const: redirectToUrl + url: + type: string + label: + type: string + required: + - id + - type + default: &a6 [] + description: The endings of the survey + thankYouCard: + type: + - object + - "null" + properties: + enabled: + type: boolean + message: + type: string + required: + - enabled + - message + description: The thank you card of the survey (deprecated) + hiddenFields: + type: object + properties: + enabled: + type: boolean + fieldIds: + type: array + items: + type: string + required: + - enabled + description: Hidden fields configuration + variables: + type: array + items: + oneOf: + - type: object + properties: + id: + type: string + name: + type: string + type: + type: string + const: number + value: + type: number + default: 0 + required: + - id + - name + - type + - value + - type: object + properties: + id: + type: string + name: + type: string + type: + type: string + const: text + value: + type: string + default: "" + required: + - id + - name + - type + - value + description: Survey variables + displayOption: + type: string + enum: &a7 + - displayOnce + - displayMultiple + - displaySome + - respondMultiple + description: Display options for the survey + recontactDays: + type: + - number + - "null" + description: Days before recontacting + displayLimit: + type: + - number + - "null" + description: Display limit for the survey + autoClose: + type: + - number + - "null" + description: Auto close time in seconds + autoComplete: + type: + - number + - "null" + description: Auto complete time in seconds + delay: + type: number + description: Delay before showing survey + runOnDate: + type: + - string + - "null" + description: Date to run the survey + closeOnDate: + type: + - string + - "null" + description: Date to close the survey + surveyClosedMessage: + type: + - object + - "null" + properties: + enabled: + type: boolean + heading: + type: string + subheading: + type: string + required: + - enabled + - heading + - subheading + description: Message shown when survey is closed + segmentId: + type: + - string + - "null" + description: ID of the segment + projectOverwrites: + type: + - object + - "null" + properties: + brandColor: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + highlightBorderColor: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + placement: + type: + - string + - "null" + enum: &a9 + - bottomLeft + - bottomRight + - topLeft + - topRight + - center + clickOutsideClose: + type: + - boolean + - "null" + darkOverlay: + type: + - boolean + - "null" + description: Project specific overwrites + styling: + type: + - object + - "null" + properties: + brandColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + questionColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + inputColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + inputBorderColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + cardBackgroundColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + cardBorderColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + cardShadowColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + highlightBorderColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + isDarkModeEnabled: + type: + - boolean + - "null" + roundness: + type: + - number + - "null" + cardArrangement: + type: + - object + - "null" + properties: + linkSurveys: + type: string + enum: &a1 + - casual + - straight + - simple + appSurveys: + type: string + enum: *a1 + required: + - linkSurveys + - appSurveys + background: + type: + - object + - "null" + properties: + bg: + type: + - string + - "null" + bgType: + type: + - string + - "null" + enum: &a8 + - animation + - color + - image + - upload + brightness: + type: + - number + - "null" + hideProgressBar: + type: + - boolean + - "null" + isLogoHidden: + type: + - boolean + - "null" + description: Survey styling configuration + singleUse: + type: object + properties: + enabled: + type: boolean + isEncrypted: + type: boolean + required: + - enabled + - isEncrypted + description: Single use configuration + isVerifyEmailEnabled: + type: boolean + description: Whether email verification is enabled + isSingleResponsePerEmailEnabled: + type: boolean + description: Whether single response per email is enabled + inlineTriggers: + type: + - array + - "null" + items: {} + description: Inline triggers configuration + isBackButtonHidden: + type: boolean + description: Whether the back button is hidden + verifyEmail: + type: object + properties: + enabled: + type: boolean + message: + type: string + required: + - enabled + - message + description: Email verification configuration (deprecated) + displayPercentage: + type: + - number + - "null" + description: The display percentage of the survey + questions: + type: array + items: {} + description: The questions of the survey. + required: + - id + - createdAt + - updatedAt + - name + - redirectUrl + - type + - status + - thankYouMessage + - showLanguageSwitch + - showThankYouMessage + - welcomeCard + - displayProgressBar + - resultShareKey + - pin + - createdBy + - environmentId + - endings + - thankYouCard + - hiddenFields + - variables + - displayOption + - recontactDays + - displayLimit + - autoClose + - autoComplete + - delay + - runOnDate + - closeOnDate + - surveyClosedMessage + - segmentId + - projectOverwrites + - styling + - singleUse + - isVerifyEmailEnabled + - isSingleResponsePerEmailEnabled + - inlineTriggers + - isBackButtonHidden + - verifyEmail + - displayPercentage + - questions responseCreate: type: object properties: @@ -968,7 +1982,7 @@ components: additionalProperties: type: string description: The data of the response - example: *a1 + example: *a2 variables: type: object additionalProperties: @@ -976,13 +1990,13 @@ components: - type: string - type: number description: The variables of the response - example: *a2 + example: *a3 ttc: type: object additionalProperties: type: number description: The TTC of the response - example: *a3 + example: *a4 meta: type: object properties: @@ -1008,7 +2022,7 @@ components: action: type: string description: The meta data of the response - example: *a4 + example: *a5 required: - surveyId - finished @@ -1085,4 +2099,487 @@ components: - type - environmentId description: Input data for creating or updating a contact attribute - + surveyInput: + type: object + properties: + name: + type: string + description: The name of the survey + redirectUrl: + type: + - string + - "null" + format: uri + description: The URL to redirect to after the survey is completed + type: + type: string + enum: + - link + - web + - website + - app + description: The type of the survey + environmentId: + type: string + description: The environment ID of the survey + questions: + type: array + items: {} + description: The questions of the survey. + endings: + type: array + items: + anyOf: + - type: object + properties: + id: + type: string + type: + type: string + const: endScreen + headline: + type: object + additionalProperties: + type: string + subheader: + type: object + additionalProperties: + type: string + buttonLabel: + type: object + additionalProperties: + type: string + buttonLink: + type: string + format: uri + imageUrl: + type: string + videoUrl: + type: string + required: + - id + - type + - type: object + properties: + id: + type: string + type: + type: string + const: redirectToUrl + url: + type: string + label: + type: string + required: + - id + - type + default: *a6 + description: The endings of the survey + thankYouCard: + type: + - object + - "null" + properties: + enabled: + type: boolean + message: + type: string + required: + - enabled + - message + description: The thank you card of the survey (deprecated) + hiddenFields: + type: object + properties: + enabled: + type: boolean + fieldIds: + type: array + items: + type: string + required: + - enabled + description: Hidden fields configuration + variables: + type: array + items: + oneOf: + - type: object + properties: + id: + type: string + name: + type: string + type: + type: string + const: number + value: + type: number + default: 0 + required: + - id + - name + - type + - type: object + properties: + id: + type: string + name: + type: string + type: + type: string + const: text + value: + type: string + default: "" + required: + - id + - name + - type + description: Survey variables + displayOption: + type: string + enum: *a7 + description: Display options for the survey + recontactDays: + type: + - number + - "null" + description: Days before recontacting + displayLimit: + type: + - number + - "null" + description: Display limit for the survey + autoClose: + type: + - number + - "null" + description: Auto close time in seconds + autoComplete: + type: + - number + - "null" + description: Auto complete time in seconds + delay: + type: number + description: Delay before showing survey + runOnDate: + type: + - string + - "null" + description: Date to run the survey + closeOnDate: + type: + - string + - "null" + description: Date to close the survey + singleUse: + type: object + properties: + enabled: + type: boolean + isEncrypted: + type: boolean + required: + - enabled + - isEncrypted + description: Single use configuration + isVerifyEmailEnabled: + type: boolean + description: Whether email verification is enabled + isSingleResponsePerEmailEnabled: + type: boolean + description: Whether single response per email is enabled + inlineTriggers: + type: + - array + - "null" + items: {} + description: Inline triggers configuration + verifyEmail: + type: object + properties: + enabled: + type: boolean + message: + type: string + required: + - enabled + - message + description: Email verification configuration (deprecated) + displayPercentage: + type: + - number + - "null" + description: The display percentage of the survey + welcomeCard: + type: object + properties: + enabled: + type: boolean + timeToFinish: + type: boolean + showResponseCount: + type: boolean + headline: + type: object + additionalProperties: + type: string + html: + type: object + additionalProperties: + type: string + fileUrl: + type: string + buttonLabel: + type: object + additionalProperties: + type: string + videoUrl: + type: string + required: + - enabled + - timeToFinish + - showResponseCount + description: The welcome card configuration + surveyClosedMessage: + type: + - object + - "null" + properties: + enabled: + type: boolean + heading: + type: string + subheading: + type: string + required: + - enabled + - heading + - subheading + description: Message shown when survey is closed + styling: + type: + - object + - "null" + properties: + brandColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + questionColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + inputColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + inputBorderColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + cardBackgroundColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + cardBorderColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + cardShadowColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + highlightBorderColor: + type: + - object + - "null" + properties: + light: + type: string + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + dark: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + required: + - light + isDarkModeEnabled: + type: + - boolean + - "null" + roundness: + type: + - number + - "null" + cardArrangement: + type: + - object + - "null" + properties: + linkSurveys: + type: string + enum: *a1 + appSurveys: + type: string + enum: *a1 + required: + - linkSurveys + - appSurveys + background: + type: + - object + - "null" + properties: + bg: + type: + - string + - "null" + bgType: + type: + - string + - "null" + enum: *a8 + brightness: + type: + - number + - "null" + hideProgressBar: + type: + - boolean + - "null" + isLogoHidden: + type: + - boolean + - "null" + description: Survey styling configuration + projectOverwrites: + type: + - object + - "null" + properties: + brandColor: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + highlightBorderColor: + type: + - string + - "null" + pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ + placement: + type: + - string + - "null" + enum: *a9 + clickOutsideClose: + type: + - boolean + - "null" + darkOverlay: + type: + - boolean + - "null" + description: Project specific overwrites + showLanguageSwitch: + type: + - boolean + - "null" + description: Whether to show the language switch + required: + - name + - type + - environmentId + - questions + - hiddenFields + - displayOption + - delay + - singleUse + - isVerifyEmailEnabled + - isSingleResponsePerEmailEnabled + - welcomeCard + description: A survey input object for creating or updating surveys + surveyId: + type: string + description: The ID of the survey diff --git a/docs/mint.json b/docs/mint.json index 4b22903445..77416d1d04 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -289,8 +289,12 @@ ] }, { - "group": "API Documentation", - "pages": ["api-reference/rest-api", "api-reference/generate-key", "api-reference/test-key"] + "group": "API v1 Documentation", + "pages": ["api-reference/introduction", "api-reference/rest-api"] + }, + { + "group": "API v2 Documentation (Draft)", + "pages": ["api-v2-reference/introduction"] } ], "redirects": [ diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx index be9ab9a783..a63ad6f98f 100644 --- a/docs/self-hosting/configuration/environment-variables.mdx +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -58,6 +58,7 @@ These variables are present inside your machine’s docker-compose file. Restart | OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | | | OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 | | OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | | +| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | | | CUSTOM_CACHE_DISABLED | Disables custom cache handler if set to 1 (required for deployment on Vercel) | optional | | Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we’ll try our best to work out a solution with you. diff --git a/openapi.yml b/openapi.yml new file mode 100644 index 0000000000..2923a8e0bd --- /dev/null +++ b/openapi.yml @@ -0,0 +1,470 @@ +openapi: 3.1.0 +info: + title: Formbricks API + description: Manage Formbricks ressources programmatically. + version: 2.0.0 +servers: + - url: https://app.formbricks.com/api + description: Formbricks Cloud +tags: + - name: responses + description: Operations for managing responses. +paths: + /responses: + get: + operationId: getResponses + summary: Get responses + description: Gets responses from the database. + tags: + - responses + parameters: + - in: query + name: surveyId + schema: + type: string + required: true + - in: query + name: limit + schema: + type: number + minimum: 1 + maximum: 100 + default: 10 + - in: query + name: skip + schema: + type: number + minimum: 0 + default: 0 + - in: query + name: sortBy + schema: + type: string + enum: + - createdAt + - updatedAt + default: createdAt + - in: query + name: order + schema: + type: string + enum: + - asc + - desc + default: desc + - in: query + name: startDate + schema: + type: string + required: true + - in: query + name: endDate + schema: + type: string + required: true + - in: query + name: contactId + schema: + type: string + required: true + responses: + "200": + description: Responses retrieved successfully. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/response" + post: + operationId: createResponse + summary: Create a response + description: Creates a response in the database. + tags: + - responses + requestBody: + required: true + description: The response to create + content: + application/json: + schema: + type: object + properties: + createdAt: + type: string + updatedAt: + type: string + environmentId: + type: string + surveyId: + type: string + userId: + type: + - string + - "null" + displayId: + type: + - string + - "null" + singleUseId: + type: + - string + - "null" + finished: + type: boolean + endingId: + type: + - string + - "null" + language: + type: string + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + ttc: + type: object + additionalProperties: + type: number + meta: + type: object + properties: + source: + type: string + url: + type: string + userAgent: + type: object + properties: + browser: + type: string + device: + type: string + os: + type: string + country: + type: string + action: + type: string + required: + - environmentId + - surveyId + - finished + - data + responses: + "201": + description: Response created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/response" + /responses/{id}: + get: + operationId: getResponse + summary: Get a response + description: Gets a response from the database. + tags: + - responses + parameters: + - in: path + name: id + description: The ID of the response + schema: + $ref: "#/components/schemas/responseId" + required: true + responses: + "200": + description: Response retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/response" + put: + operationId: updateResponse + summary: Update a response + description: Updates a response in the database. + tags: + - responses + parameters: + - in: path + name: id + description: The ID of the response + schema: + $ref: "#/components/schemas/responseId" + required: true + requestBody: + required: true + description: The response to update + content: + application/json: + schema: + type: object + properties: + createdAt: + type: string + updatedAt: + type: string + environmentId: + type: string + surveyId: + type: string + userId: + type: + - string + - "null" + displayId: + type: + - string + - "null" + singleUseId: + type: + - string + - "null" + finished: + type: boolean + endingId: + type: + - string + - "null" + language: + type: string + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + ttc: + type: object + additionalProperties: + type: number + meta: + type: object + properties: + source: + type: string + url: + type: string + userAgent: + type: object + properties: + browser: + type: string + device: + type: string + os: + type: string + country: + type: string + action: + type: string + required: + - environmentId + - surveyId + - finished + - data + responses: + "200": + description: Response updated successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/response" + delete: + operationId: deleteResponse + summary: Delete a response + description: Deletes a response from the database. + tags: + - responses + parameters: + - in: path + name: id + description: The ID of the response + schema: + $ref: "#/components/schemas/responseId" + required: true + responses: + "200": + description: Response deleted successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/response" +components: + schemas: + response: + type: object + properties: + id: + type: string + description: The ID of the response + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + finished: + type: boolean + description: Whether the response is finished + example: true + surveyId: + type: string + description: The ID of the survey + contactId: + type: + - string + - "null" + description: The ID of the contact + endingId: + type: + - string + - "null" + description: The ID of the ending + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: + question1: answer1 + question2: 2 + question3: + - answer3 + - answer4 + question4: + subquestion1: answer5 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: + variable1: answer1 + variable2: 2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: + question1: 10 + question2: 20 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: + source: https://example.com + url: https://example.com + userAgent: + browser: Chrome + os: Windows + device: Desktop + country: US + action: click + contactAttributes: + type: + - object + - "null" + additionalProperties: + type: string + description: The attributes of the contact + example: + attribute1: value1 + attribute2: value2 + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + language: + type: + - string + - "null" + description: The language of the response + example: en + displayId: + type: + - string + - "null" + description: The display ID of the response + required: + - id + - createdAt + - updatedAt + - finished + - surveyId + - contactId + - endingId + - data + - variables + - ttc + - meta + - contactAttributes + - singleUseId + - language + - displayId + responseId: + type: string + description: The ID of the response + diff --git a/packages/database/package.json b/packages/database/package.json index 2a3bbeb157..a49df58721 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -27,7 +27,8 @@ "dependencies": { "@prisma/client": "6.0.1", "@prisma/extension-accelerate": "1.2.1", - "dotenv-cli": "7.4.4" + "dotenv-cli": "7.4.4", + "zod-openapi": "4.2.3" }, "devDependencies": { "@formbricks/config-typescript": "workspace:*", diff --git a/packages/database/zod/contact-attribute-keys.ts b/packages/database/zod/contact-attribute-keys.ts new file mode 100644 index 0000000000..40902f18cf --- /dev/null +++ b/packages/database/zod/contact-attribute-keys.ts @@ -0,0 +1,47 @@ +import { type ContactAttributeKey, ContactAttributeType } from "@prisma/client"; +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; + +extendZodWithOpenApi(z); + +export const ZContactAttributeKey = z.object({ + id: z.string().cuid2().openapi({ + description: "The ID of the contact attribute key", + }), + createdAt: z.coerce.date().openapi({ + description: "The date and time the contact attribute key was created", + example: "2021-01-01T00:00:00.000Z", + }), + updatedAt: z.coerce.date().openapi({ + description: "The date and time the contact attribute key was last updated", + example: "2021-01-01T00:00:00.000Z", + }), + isUnique: z.boolean().openapi({ + description: "Whether the attribute must have unique values across contacts", + example: false, + }), + key: z.string().openapi({ + description: "The attribute identifier used in the system", + example: "email", + }), + name: z.string().nullable().openapi({ + description: "Display name for the attribute", + example: "Email Address", + }), + description: z.string().nullable().openapi({ + description: "Description of the attribute", + example: "The user's email address", + }), + type: z.nativeEnum(ContactAttributeType).openapi({ + description: "Whether this is a default or custom attribute", + example: "custom", + }), + environmentId: z.string().cuid2().openapi({ + description: "The ID of the environment this attribute belongs to", + }), +}) satisfies z.ZodType; + +ZContactAttributeKey.openapi({ + ref: "contactAttributeKey", + description: "Defines a possible attribute that can be assigned to contacts", +}); diff --git a/packages/database/zod/contact-attributes.ts b/packages/database/zod/contact-attributes.ts new file mode 100644 index 0000000000..296bc6dae5 --- /dev/null +++ b/packages/database/zod/contact-attributes.ts @@ -0,0 +1,34 @@ +import type { ContactAttribute } from "@prisma/client"; +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; + +extendZodWithOpenApi(z); + +export const ZContactAttribute = z.object({ + id: z.string().cuid2().openapi({ + description: "The ID of the contact attribute", + }), + createdAt: z.coerce.date().openapi({ + description: "The date and time the contact attribute was created", + example: "2021-01-01T00:00:00.000Z", + }), + updatedAt: z.coerce.date().openapi({ + description: "The date and time the contact attribute was last updated", + example: "2021-01-01T00:00:00.000Z", + }), + attributeKeyId: z.string().cuid2().openapi({ + description: "The ID of the attribute key", + }), + contactId: z.string().cuid2().openapi({ + description: "The ID of the contact", + }), + value: z.string().openapi({ + description: "The value of the attribute", + example: "example@email.com", + }), +}) satisfies z.ZodType; + +ZContactAttribute.openapi({ + ref: "contactAttribute", + description: "A contact attribute value associated with a contact", +}); diff --git a/packages/database/zod/contact.ts b/packages/database/zod/contact.ts new file mode 100644 index 0000000000..93eebd36e1 --- /dev/null +++ b/packages/database/zod/contact.ts @@ -0,0 +1,30 @@ +import type { Contact } from "@prisma/client"; +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; + +extendZodWithOpenApi(z); + +export const ZContact = z.object({ + id: z.string().cuid2().openapi({ + description: "Unique identifier for the contact", + }), + userId: z.string().nullable().openapi({ + description: "Optional external user identifier", + }), + createdAt: z.coerce.date().openapi({ + description: "When the contact was created", + example: "2021-01-01T00:00:00.000Z", + }), + updatedAt: z.coerce.date().openapi({ + description: "When the contact was last updated", + example: "2021-01-01T00:00:00.000Z", + }), + environmentId: z.string().openapi({ + description: "The environment this contact belongs to", + }), +}) satisfies z.ZodType; + +ZContact.openapi({ + ref: "contact", + description: "A person or user who can receive and respond to surveys", +}); diff --git a/packages/database/zod/responses.ts b/packages/database/zod/responses.ts new file mode 100644 index 0000000000..e6881e5589 --- /dev/null +++ b/packages/database/zod/responses.ts @@ -0,0 +1,116 @@ +import type { Response } from "@prisma/client"; +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; + +extendZodWithOpenApi(z); + +export const ZResponse = z.object({ + id: z.string().cuid2().openapi({ + description: "The ID of the response", + }), + createdAt: z.coerce.date().openapi({ + description: "The date and time the response was created", + example: "2021-01-01T00:00:00.000Z", + }), + updatedAt: z.coerce.date().openapi({ + description: "The date and time the response was last updated", + example: "2021-01-01T00:00:00.000Z", + }), + finished: z.boolean().openapi({ + description: "Whether the response is finished", + example: true, + }), + surveyId: z.string().cuid2().openapi({ + description: "The ID of the survey", + }), + contactId: z.string().cuid2().nullable().openapi({ + description: "The ID of the contact", + }), + endingId: z.string().cuid2().nullable().openapi({ + description: "The ID of the ending", + }), + data: z.record(z.union([z.string(), z.number(), z.array(z.string()), z.record(z.string())])).openapi({ + description: "The data of the response", + example: { + question1: "answer1", + question2: 2, + question3: ["answer3", "answer4"], + question4: { + subquestion1: "answer5", + }, + }, + }), + variables: z.record(z.union([z.string(), z.number()])).openapi({ + description: "The variables of the response", + example: { + variable1: "answer1", + variable2: 2, + }, + }), + ttc: z.record(z.number()).openapi({ + description: "The TTC of the response", + example: { + question1: 10, + question2: 20, + }, + }), + meta: z + .object({ + source: z.string().optional().openapi({ + description: "The source of the response", + example: "https://example.com", + }), + url: z.string().optional().openapi({ + description: "The URL of the response", + example: "https://example.com", + }), + userAgent: z + .object({ + browser: z.string().optional(), + os: z.string().optional(), + device: z.string().optional(), + }) + .optional(), + country: z.string().optional(), + action: z.string().optional(), + }) + .openapi({ + description: "The meta data of the response", + example: { + source: "https://example.com", + url: "https://example.com", + userAgent: { + browser: "Chrome", + os: "Windows", + device: "Desktop", + }, + country: "US", + action: "click", + }, + }), + contactAttributes: z + .record(z.string()) + .nullable() + .openapi({ + description: "The attributes of the contact", + example: { + attribute1: "value1", + attribute2: "value2", + }, + }), + singleUseId: z.string().nullable().openapi({ + description: "The single use ID of the response", + }), + language: z.string().nullable().openapi({ + description: "The language of the response", + example: "en", + }), + displayId: z.string().nullable().openapi({ + description: "The display ID of the response", + }), +}) satisfies z.ZodType; + +ZResponse.openapi({ + ref: "response", + description: "A response", +}); diff --git a/packages/database/zod/surveys.ts b/packages/database/zod/surveys.ts new file mode 100644 index 0000000000..715471a5e4 --- /dev/null +++ b/packages/database/zod/surveys.ts @@ -0,0 +1,235 @@ +import { type Survey, SurveyStatus, SurveyType } from "@prisma/client"; +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; +// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package +import { ZSurveyEnding, ZSurveyQuestion, ZSurveyVariable } from "../../types/surveys/types"; + +extendZodWithOpenApi(z); + +const ZColor = z.string().regex(/^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/); + +export const ZStylingColor = z.object({ + light: ZColor, + dark: ZColor.nullish(), +}); + +export const ZCardArrangementOptions = z.enum(["casual", "straight", "simple"]); + +export const ZCardArrangement = z.object({ + linkSurveys: ZCardArrangementOptions, + appSurveys: ZCardArrangementOptions, +}); + +export const ZSurveyStylingBackground = z.object({ + bg: z.string().nullish(), + bgType: z.enum(["animation", "color", "image", "upload"]).nullish(), + brightness: z.number().nullish(), +}); + +export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]); + +const ZSurveyBase = z.object({ + id: z.string().cuid2().openapi({ + description: "The ID of the survey", + }), + createdAt: z.coerce.date().openapi({ + description: "The date and time the survey was created", + example: "2021-01-01T00:00:00.000Z", + }), + updatedAt: z.coerce.date().openapi({ + description: "The date and time the survey was last updated", + example: "2021-01-01T00:00:00.000Z", + }), + name: z.string().openapi({ + description: "The name of the survey", + }), + redirectUrl: z.string().url().nullable().openapi({ + description: "The URL to redirect to after the survey is completed", + }), + type: z.nativeEnum(SurveyType).openapi({ + description: "The type of the survey", + }), + status: z.nativeEnum(SurveyStatus).openapi({ + description: "The status of the survey", + }), + thankYouMessage: z.string().nullable().openapi({ + description: "The thank you message of the survey", + }), + showLanguageSwitch: z.boolean().nullable().openapi({ + description: "Whether to show the language switch", + }), + showThankYouMessage: z.boolean().nullable().openapi({ + description: "Whether to show the thank you message", + }), + welcomeCard: z + .object({ + enabled: z.boolean(), + timeToFinish: z.boolean(), + showResponseCount: z.boolean(), + headline: z.record(z.string()).optional(), + html: z.record(z.string()).optional(), + fileUrl: z.string().optional(), + buttonLabel: z.record(z.string()).optional(), + videoUrl: z.string().optional(), + }) + .openapi({ + description: "The welcome card configuration", + }), + displayProgressBar: z.boolean().nullable().openapi({ + description: "Whether to display the progress bar", + }), + resultShareKey: z.string().nullable().openapi({ + description: "The result share key of the survey", + }), + pin: z.string().nullable().openapi({ + description: "The pin of the survey", + }), + createdBy: z.string().nullable().openapi({ + description: "The user who created the survey", + }), + environmentId: z.string().cuid2().openapi({ + description: "The environment ID of the survey", + }), + questions: z.array(ZSurveyQuestion).openapi({ + description: "The questions of the survey", + }) as z.ZodType, + endings: z.array(ZSurveyEnding).default([]).openapi({ + description: "The endings of the survey", + }) as z.ZodType, + thankYouCard: z + .object({ + enabled: z.boolean(), + message: z.string(), + }) + .nullable() + .openapi({ + description: "The thank you card of the survey (deprecated)", + }), + hiddenFields: z + .object({ + enabled: z.boolean(), + fieldIds: z.array(z.string()).optional(), + }) + .openapi({ + description: "Hidden fields configuration", + }), + variables: z.array(ZSurveyVariable).openapi({ + description: "Survey variables", + }) as z.ZodType, + displayOption: z.enum(["displayOnce", "displayMultiple", "displaySome", "respondMultiple"]).openapi({ + description: "Display options for the survey", + }), + recontactDays: z.number().nullable().openapi({ + description: "Days before recontacting", + }), + displayLimit: z.number().nullable().openapi({ + description: "Display limit for the survey", + }), + autoClose: z.number().nullable().openapi({ + description: "Auto close time in seconds", + }), + autoComplete: z.number().nullable().openapi({ + description: "Auto complete time in seconds", + }), + delay: z.number().openapi({ + description: "Delay before showing survey", + }), + runOnDate: z.date().nullable().openapi({ + description: "Date to run the survey", + }), + closeOnDate: z.date().nullable().openapi({ + description: "Date to close the survey", + }), + surveyClosedMessage: z + .object({ + enabled: z.boolean(), + heading: z.string(), + subheading: z.string(), + }) + .nullable() + .openapi({ + description: "Message shown when survey is closed", + }), + segmentId: z.string().nullable().openapi({ + description: "ID of the segment", + }), + projectOverwrites: z + .object({ + brandColor: ZColor.nullish(), + highlightBorderColor: ZColor.nullish(), + placement: ZPlacement.nullish(), + clickOutsideClose: z.boolean().nullish(), + darkOverlay: z.boolean().nullish(), + }) + .nullable() + .openapi({ + description: "Project specific overwrites", + }), + styling: z + .object({ + brandColor: ZStylingColor.nullish(), + questionColor: ZStylingColor.nullish(), + inputColor: ZStylingColor.nullish(), + inputBorderColor: ZStylingColor.nullish(), + cardBackgroundColor: ZStylingColor.nullish(), + cardBorderColor: ZStylingColor.nullish(), + cardShadowColor: ZStylingColor.nullish(), + highlightBorderColor: ZStylingColor.nullish(), + isDarkModeEnabled: z.boolean().nullish(), + roundness: z.number().nullish(), + cardArrangement: ZCardArrangement.nullish(), + background: ZSurveyStylingBackground.nullish(), + hideProgressBar: z.boolean().nullish(), + isLogoHidden: z.boolean().nullish(), + }) + .nullable() + .openapi({ + description: "Survey styling configuration", + }), + singleUse: z + .object({ + enabled: z.boolean(), + isEncrypted: z.boolean(), + }) + .openapi({ + description: "Single use configuration", + }), + isVerifyEmailEnabled: z.boolean().openapi({ + description: "Whether email verification is enabled", + }), + isSingleResponsePerEmailEnabled: z.boolean().openapi({ + description: "Whether single response per email is enabled", + }), + inlineTriggers: z.array(z.any()).nullable().openapi({ + description: "Inline triggers configuration", + }), + isBackButtonHidden: z.boolean().openapi({ + description: "Whether the back button is hidden", + }), + verifyEmail: z + .object({ + enabled: z.boolean(), + message: z.string(), + }) + .openapi({ + description: "Email verification configuration (deprecated)", + }), + displayPercentage: z.number().nullable().openapi({ + description: "The display percentage of the survey", + }) as z.ZodType, +}); + +export const ZSurvey = ZSurveyBase satisfies z.ZodType; + +export const ZSurveyWithoutQuestionType = ZSurveyBase.omit({ + questions: true, +}).extend({ + questions: z.array(z.any()).openapi({ + description: "The questions of the survey.", + }), +}); + +ZSurvey.openapi({ + ref: "survey", + description: "A survey", +}); diff --git a/packages/js-core/src/lib/person-state.ts b/packages/js-core/src/lib/person-state.ts index 6c710baf77..dfe0464c10 100644 --- a/packages/js-core/src/lib/person-state.ts +++ b/packages/js-core/src/lib/person-state.ts @@ -12,6 +12,7 @@ export const DEFAULT_PERSON_STATE_NO_USER_ID: TJsPersonState = { expiresAt: null, data: { userId: null, + contactId: null, segments: [], displays: [], responses: [], @@ -71,6 +72,7 @@ export const fetchPersonState = async ( expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes data: { userId, + contactId: null, segments: [], displays: [], responses: [], diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index 27de87a074..3f4080e7ed 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -12,7 +12,7 @@ export const verifyPassword = async (password: string, hashedPassword: string) = return isValid; }; -export const hasOrganizationAccess = async (userId: string, organizationId: string) => { +export const hasOrganizationAccess = async (userId: string, organizationId: string): Promise => { const membership = await prisma.membership.findUnique({ where: { userId_organizationId: { @@ -22,11 +22,7 @@ export const hasOrganizationAccess = async (userId: string, organizationId: stri }, }); - if (membership) { - return true; - } - - return false; + return !!membership; }; export const isManagerOrOwner = async (userId: string, organizationId: string) => { diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 3e6cdcde7d..26895a4f21 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -163,6 +163,11 @@ export const CLIENT_SIDE_API_RATE_LIMIT = { interval: 60, // 1 minute allowedPerInterval: 100, }; +export const MANAGEMENT_API_RATE_LIMIT = { + interval: 60, // 1 minute + allowedPerInterval: 100, +}; + export const SHARE_RATE_LIMIT = { interval: 60 * 60, // 60 minutes allowedPerInterval: 100, @@ -193,6 +198,7 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY; export const REDIS_URL = env.REDIS_URL; export const REDIS_HTTP_URL = env.REDIS_HTTP_URL; export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1"; +export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY; export const BREVO_API_KEY = env.BREVO_API_KEY; export const BREVO_LIST_ID = env.BREVO_LIST_ID; @@ -264,3 +270,7 @@ export const IS_INTERCOM_CONFIGURED = Boolean(env.NEXT_PUBLIC_INTERCOM_APP_ID && export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY; export const IS_TURNSTILE_CONFIGURED = Boolean(env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY); + +export const IS_PRODUCTION = env.NODE_ENV === "production"; + +export const IS_DEVELOPMENT = env.NODE_ENV === "development"; diff --git a/packages/lib/env.ts b/packages/lib/env.ts index d87e546f69..8e3b77900e 100644 --- a/packages/lib/env.ts +++ b/packages/lib/env.ts @@ -100,6 +100,8 @@ export const env = createEnv({ LANGFUSE_SECRET_KEY: z.string().optional(), LANGFUSE_PUBLIC_KEY: z.string().optional(), LANGFUSE_BASEURL: z.string().optional(), + UNKEY_ROOT_KEY: z.string().optional(), + NODE_ENV: z.enum(["development", "production", "test"]).optional(), }, /* @@ -217,5 +219,7 @@ export const env = createEnv({ VERCEL_URL: process.env.VERCEL_URL, WEBAPP_URL: process.env.WEBAPP_URL, UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY, + UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY, + NODE_ENV: process.env.NODE_ENV, }, }); diff --git a/packages/lib/posthogServer.ts b/packages/lib/posthogServer.ts index 6b10581990..900189aaee 100644 --- a/packages/lib/posthogServer.ts +++ b/packages/lib/posthogServer.ts @@ -1,12 +1,10 @@ import { PostHog } from "posthog-node"; import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations"; import { cache } from "./cache"; +import { IS_PRODUCTION } from "./constants"; import { env } from "./env"; -const enabled = - process.env.NODE_ENV === "production" && - env.NEXT_PUBLIC_POSTHOG_API_HOST && - env.NEXT_PUBLIC_POSTHOG_API_KEY; +const enabled = IS_PRODUCTION && env.NEXT_PUBLIC_POSTHOG_API_HOST && env.NEXT_PUBLIC_POSTHOG_API_KEY; export const capturePosthogEnvironmentEvent = async ( environmentId: string, diff --git a/packages/lib/telemetry.ts b/packages/lib/telemetry.ts index 530d0071d1..6550da5a4a 100644 --- a/packages/lib/telemetry.ts +++ b/packages/lib/telemetry.ts @@ -2,6 +2,7 @@ and how we can improve it. All data including the IP address is collected anonymously and we cannot trace anything back to you or your customers. If you still want to disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */ +import { IS_PRODUCTION } from "./constants"; import { env } from "./env"; const crypto = require("crypto"); @@ -14,7 +15,7 @@ const getTelemetryId = (): string => { }; export const captureTelemetry = async (eventName: string, properties = {}) => { - if (env.TELEMETRY_DISABLED !== "1" && process.env.NODE_ENV === "production") { + if (env.TELEMETRY_DISABLED !== "1" && IS_PRODUCTION) { try { await fetch("https://telemetry.formbricks.com/capture/", { method: "POST", diff --git a/packages/react-native/src/types/error.ts b/packages/react-native/src/types/error.ts index ea78317c81..4d6898a5ab 100644 --- a/packages/react-native/src/types/error.ts +++ b/packages/react-native/src/types/error.ts @@ -21,15 +21,16 @@ export const err = (error: E): ResultError => ({ export interface ApiErrorResponse { code: - | "not_found" - | "gone" - | "bad_request" - | "internal_server_error" - | "unauthorized" - | "method_not_allowed" - | "not_authenticated" - | "forbidden" - | "network_error"; + | "not_found" + | "gone" + | "bad_request" + | "internal_server_error" + | "unauthorized" + | "method_not_allowed" + | "not_authenticated" + | "forbidden" + | "network_error" + | "too_many_requests"; message: string; status: number; url?: URL; diff --git a/packages/types/auth.ts b/packages/types/auth.ts index 1c45a5e77e..a8dc9a7f84 100644 --- a/packages/types/auth.ts +++ b/packages/types/auth.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import { ZUser } from "./user"; -const ZAuthSession = z.object({ +export const ZAuthSession = z.object({ user: ZUser, }); -const ZAuthenticationApiKey = z.object({ +export const ZAuthenticationApiKey = z.object({ type: z.literal("apiKey"), environmentId: z.string(), + hashedApiKey: z.string(), }); export type TAuthSession = z.infer; diff --git a/packages/types/errors.ts b/packages/types/errors.ts index 2d4d2575cd..7a7a3d5bb1 100644 --- a/packages/types/errors.ts +++ b/packages/types/errors.ts @@ -121,18 +121,19 @@ export type { NetworkError, ForbiddenError }; export interface ApiErrorResponse { code: - | "not_found" - | "gone" - | "bad_request" - | "internal_server_error" - | "unauthorized" - | "method_not_allowed" - | "not_authenticated" - | "forbidden" - | "network_error"; + | "not_found" + | "gone" + | "bad_request" + | "internal_server_error" + | "unauthorized" + | "method_not_allowed" + | "not_authenticated" + | "forbidden" + | "network_error" + | "too_many_requests"; message: string; status: number; - url: URL; + url?: URL; details?: Record; responseMessage?: string; } diff --git a/packages/types/js.ts b/packages/types/js.ts index 558ed0b0e3..7ff7a5a1ef 100644 --- a/packages/types/js.ts +++ b/packages/types/js.ts @@ -84,6 +84,7 @@ export const ZJsPersonState = z.object({ expiresAt: z.date().nullable(), data: z.object({ userId: z.string().nullable(), + contactId: z.string().nullable(), segments: z.array(ZId), // segment ids the person belongs to displays: z.array( z.object({ diff --git a/packages/types/package.json b/packages/types/package.json index 6492b5b7e4..8c40369e2e 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -13,6 +13,7 @@ "@formbricks/database": "workspace:*" }, "dependencies": { - "zod": "3.24.1" + "zod": "3.24.1", + "zod-openapi": "4.2.3" } } diff --git a/playwright.config.ts b/playwright.config.ts index 7c29f0a8eb..b1362e8a94 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,5 @@ import { defineConfig, devices } from "@playwright/test"; -// import os from "os"; - /** * Read environment variables from file. * https://github.com/motdotla/dotenv diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 326fca1e87..0a8c63488b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,7 +173,7 @@ importers: version: 8.4.7(prettier@3.4.2) tsup: specifier: 8.3.5 - version: 8.3.5(@microsoft/api-extractor@7.49.1(@types/node@22.10.2))(jiti@2.4.1)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.7.0) + version: 8.3.5(@microsoft/api-extractor@7.49.1(@types/node@22.10.2))(jiti@2.4.1)(postcss@8.5.3)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.7.0) vite: specifier: 6.0.9 version: 6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) @@ -339,6 +339,9 @@ importers: '@tolgee/react': specifier: 6.0.1 version: 6.0.1(react@19.0.0) + '@unkey/ratelimit': + specifier: 0.5.5 + version: 0.5.5 '@vercel/functions': specifier: 1.5.2 version: 1.5.2(@aws-sdk/credential-provider-web-identity@3.734.0(aws-crt@1.25.3)) @@ -504,6 +507,9 @@ importers: ua-parser-js: specifier: 2.0.0 version: 2.0.0 + uuid: + specifier: 11.1.0 + version: 11.1.0 webpack: specifier: 5.97.1 version: 5.97.1 @@ -513,6 +519,9 @@ importers: zod: specifier: 3.24.1 version: 3.24.1 + zod-openapi: + specifier: 4.2.3 + version: 4.2.3(zod@3.24.1) devDependencies: '@formbricks/config-typescript': specifier: workspace:* @@ -546,16 +555,19 @@ importers: version: 1.5.5 '@vitest/coverage-v8': specifier: 2.1.8 - version: 2.1.8(vitest@2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)) + version: 2.1.8(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) vite: - specifier: 6.0.9 - version: 6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + specifier: 6.2.0 + version: 6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + vite-tsconfig-paths: + specifier: 5.1.4 + version: 5.1.4(typescript@5.7.2)(vite@6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) vitest: - specifier: 2.1.9 - version: 2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0) + specifier: 3.0.7 + version: 3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) vitest-mock-extended: specifier: 2.0.2 - version: 2.0.2(typescript@5.7.2)(vitest@2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)) + version: 2.0.2(typescript@5.7.2)(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) packages/api: devDependencies: @@ -600,7 +612,7 @@ importers: version: 8.18.0(eslint@8.57.0)(typescript@5.7.2) '@vercel/style-guide': specifier: 6.0.0 - version: 6.0.0(@next/eslint-plugin-next@15.1.0)(eslint@8.57.0)(prettier@3.4.2)(typescript@5.7.2)(vitest@3.0.5(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + version: 6.0.0(@next/eslint-plugin-next@15.1.0)(eslint@8.57.0)(prettier@3.4.2)(typescript@5.7.2)(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) eslint-config-next: specifier: 15.1.0 version: 15.1.0(eslint@8.57.0)(typescript@5.7.2) @@ -664,6 +676,9 @@ importers: dotenv-cli: specifier: 7.4.4 version: 7.4.4 + zod-openapi: + specifier: 4.2.3 + version: 4.2.3(zod@3.24.1) devDependencies: '@formbricks/config-typescript': specifier: workspace:* @@ -957,6 +972,9 @@ importers: zod: specifier: 3.24.1 version: 3.24.1 + zod-openapi: + specifier: 4.2.3 + version: 4.2.3(zod@3.24.1) devDependencies: '@formbricks/config-typescript': specifier: workspace:* @@ -5991,6 +6009,18 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@unkey/api@0.33.1': + resolution: {integrity: sha512-HXFQOGjO3S0+N6GMBOz9lkn2Fp41rdgNrafBkjRg8IjOsNv6SNdWrXZSELONinZYycr7Wxc/t7Vhx7xUdb0cdA==} + + '@unkey/error@0.2.0': + resolution: {integrity: sha512-DFGb4A7SrusZPP0FYuRIF0CO+Gi4etLUAEJ6EKc+TKYmscL0nEJ2Pr38FyX9MvjI4Wx5l35Wc9KsBjMm9Ybh7w==} + + '@unkey/ratelimit@0.5.5': + resolution: {integrity: sha512-79Xv7lZFHqScGzBQpO3hh2SZBhujXDDkyH/izWzeXcJ5sFYO+PsC+VaAFagEinbHbrx116RkIRIqbDrfZfRpxA==} + + '@unkey/rbac@0.3.1': + resolution: {integrity: sha512-Hj+52XRIlBBl3/qOUq9K71Fwy3PWExBQOpOClVYHdrcmbgqNL6L4EdW/BzliLhqPCdwZTPVSJTnZ3Hw4ZYixsQ==} + '@urql/core@5.1.0': resolution: {integrity: sha512-yC3sw8yqjbX45GbXxfiBY8GLYCiyW/hLBbQF9l3TJrv4ro00Y0ChkKaD9I2KntRxAVm9IYBqh0awX8fwWAe/Yw==} @@ -6098,6 +6128,9 @@ packages: '@vitest/expect@3.0.5': resolution: {integrity: sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==} + '@vitest/expect@3.0.7': + resolution: {integrity: sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -6120,30 +6153,47 @@ packages: vite: optional: true + '@vitest/mocker@3.0.7': + resolution: {integrity: sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.0.5': resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} - '@vitest/pretty-format@2.1.8': - resolution: {integrity: sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==} - '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} '@vitest/pretty-format@3.0.5': resolution: {integrity: sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==} + '@vitest/pretty-format@3.0.7': + resolution: {integrity: sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} '@vitest/runner@3.0.5': resolution: {integrity: sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==} + '@vitest/runner@3.0.7': + resolution: {integrity: sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} '@vitest/snapshot@3.0.5': resolution: {integrity: sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==} + '@vitest/snapshot@3.0.7': + resolution: {integrity: sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==} + '@vitest/spy@2.0.5': resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} @@ -6153,18 +6203,21 @@ packages: '@vitest/spy@3.0.5': resolution: {integrity: sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==} + '@vitest/spy@3.0.7': + resolution: {integrity: sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==} + '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} - '@vitest/utils@2.1.8': - resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} - '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} '@vitest/utils@3.0.5': resolution: {integrity: sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==} + '@vitest/utils@3.0.7': + resolution: {integrity: sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==} + '@volar/language-core@2.4.11': resolution: {integrity: sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==} @@ -6937,10 +6990,6 @@ packages: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} - chai@5.1.2: - resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} - engines: {node: '>=12'} - chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -11104,8 +11153,8 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.2: - resolution: {integrity: sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==} + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -13105,6 +13154,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. @@ -13156,6 +13209,11 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-node@3.0.7: + resolution: {integrity: sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-dts@4.3.0: resolution: {integrity: sha512-LkBJh9IbLwL6/rxh0C1/bOurDrIEmRE7joC+jFdOEEciAFPbpEKOLSAr5nNh5R7CJ45cMbksTrFfy52szzC5eA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -13250,6 +13308,46 @@ packages: yaml: optional: true + vite@6.2.0: + resolution: {integrity: sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest-mock-extended@2.0.2: resolution: {integrity: sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==} peerDependencies: @@ -13309,6 +13407,34 @@ packages: jsdom: optional: true + vitest@3.0.7: + resolution: {integrity: sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.0.7 + '@vitest/ui': 3.0.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -13648,6 +13774,12 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod-openapi@4.2.3: + resolution: {integrity: sha512-i0SqpcdXfsvVWTIY1Jl3Tk421s9fBIkpXvaA86zDas+8FjfZjm+GX6ot6SPB2SyuHwUNTN02gE5uIVlYXlyrDQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.21.4 + zod-to-json-schema@3.24.1: resolution: {integrity: sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==} peerDependencies: @@ -15038,7 +15170,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.26.5 - '@babel/template': 7.25.9 + '@babel/template': 7.26.9 '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.26.0)': dependencies: @@ -19732,7 +19864,7 @@ snapshots: '@storybook/instrumenter@8.4.7(storybook@8.4.7(prettier@3.4.2))': dependencies: '@storybook/global': 5.0.0 - '@vitest/utils': 2.1.8 + '@vitest/utils': 2.1.9 storybook: 8.4.7(prettier@3.4.2) '@storybook/manager-api@8.4.7(storybook@8.4.7(prettier@3.4.2))': @@ -20400,6 +20532,23 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@unkey/api@0.33.1': + dependencies: + '@unkey/rbac': 0.3.1 + + '@unkey/error@0.2.0': + dependencies: + zod: 3.24.1 + + '@unkey/ratelimit@0.5.5': + dependencies: + '@unkey/api': 0.33.1 + + '@unkey/rbac@0.3.1': + dependencies: + '@unkey/error': 0.2.0 + zod: 3.24.1 + '@urql/core@5.1.0': dependencies: '@0no-co/graphql.web': 1.0.13 @@ -20437,7 +20586,7 @@ snapshots: next: 15.1.2(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 - '@vercel/style-guide@6.0.0(@next/eslint-plugin-next@15.1.0)(eslint@8.57.0)(prettier@3.4.2)(typescript@5.7.2)(vitest@3.0.5(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))': + '@vercel/style-guide@6.0.0(@next/eslint-plugin-next@15.1.0)(eslint@8.57.0)(prettier@3.4.2)(typescript@5.7.2)(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@babel/core': 7.26.0 '@babel/eslint-parser': 7.26.5(@babel/core@7.26.0)(eslint@8.57.0) @@ -20457,7 +20606,7 @@ snapshots: eslint-plugin-testing-library: 6.5.0(eslint@8.57.0)(typescript@5.7.2) eslint-plugin-tsdoc: 0.2.17 eslint-plugin-unicorn: 51.0.1(eslint@8.57.0) - eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2)(vitest@3.0.5(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2)(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) prettier-plugin-packagejson: 2.5.8(prettier@3.4.2) optionalDependencies: '@next/eslint-plugin-next': 15.1.0 @@ -20482,7 +20631,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.1.8(vitest@2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0))': + '@vitest/coverage-v8@2.1.8(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -20496,7 +20645,7 @@ snapshots: std-env: 3.8.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0) + vitest: 3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -20522,7 +20671,7 @@ snapshots: dependencies: '@vitest/spy': 2.0.5 '@vitest/utils': 2.0.5 - chai: 5.1.2 + chai: 5.2.0 tinyrainbow: 1.2.0 '@vitest/expect@2.1.9': @@ -20539,6 +20688,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 + '@vitest/expect@3.0.7': + dependencies: + '@vitest/spy': 3.0.7 + '@vitest/utils': 3.0.7 + chai: 5.2.0 + tinyrainbow: 2.0.0 + '@vitest/mocker@2.1.9(vite@5.4.14(@types/node@22.10.2)(lightningcss@1.27.0)(terser@5.37.0))': dependencies: '@vitest/spy': 2.1.9 @@ -20555,11 +20711,15 @@ snapshots: optionalDependencies: vite: 6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) - '@vitest/pretty-format@2.0.5': + '@vitest/mocker@3.0.7(vite@6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))': dependencies: - tinyrainbow: 1.2.0 + '@vitest/spy': 3.0.7 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) - '@vitest/pretty-format@2.1.8': + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -20571,6 +20731,10 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@3.0.7': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -20581,6 +20745,11 @@ snapshots: '@vitest/utils': 3.0.5 pathe: 2.0.3 + '@vitest/runner@3.0.7': + dependencies: + '@vitest/utils': 3.0.7 + pathe: 2.0.3 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -20593,6 +20762,12 @@ snapshots: magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/snapshot@3.0.7': + dependencies: + '@vitest/pretty-format': 3.0.7 + magic-string: 0.30.17 + pathe: 2.0.3 + '@vitest/spy@2.0.5': dependencies: tinyspy: 3.0.2 @@ -20605,6 +20780,10 @@ snapshots: dependencies: tinyspy: 3.0.2 + '@vitest/spy@3.0.7': + dependencies: + tinyspy: 3.0.2 + '@vitest/utils@2.0.5': dependencies: '@vitest/pretty-format': 2.0.5 @@ -20612,12 +20791,6 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 - '@vitest/utils@2.1.8': - dependencies: - '@vitest/pretty-format': 2.1.8 - loupe: 3.1.3 - tinyrainbow: 1.2.0 - '@vitest/utils@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -20630,6 +20803,12 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 + '@vitest/utils@3.0.7': + dependencies: + '@vitest/pretty-format': 3.0.7 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + '@volar/language-core@2.4.11': dependencies: '@volar/source-map': 2.4.11 @@ -21562,14 +21741,6 @@ snapshots: adler-32: 1.3.1 crc-32: 1.2.2 - chai@5.1.2: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.1.3 - pathval: 2.0.0 - chai@5.2.0: dependencies: assertion-error: 2.0.1 @@ -22905,13 +23076,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2)(vitest@3.0.5(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)): + eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2)(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)): dependencies: '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.7.2) eslint: 8.57.0 optionalDependencies: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0)(typescript@5.7.2) - vitest: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + vitest: 3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript @@ -26515,12 +26686,12 @@ snapshots: postcss: 8.4.49 ts-node: 10.9.2(@types/node@22.10.2)(typescript@5.7.2) - postcss-load-config@6.0.1(jiti@2.4.1)(postcss@8.5.2)(tsx@4.19.2)(yaml@2.7.0): + postcss-load-config@6.0.1(jiti@2.4.1)(postcss@8.5.3)(tsx@4.19.2)(yaml@2.7.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.4.1 - postcss: 8.5.2 + postcss: 8.5.3 tsx: 4.19.2 yaml: 2.7.0 @@ -26553,12 +26724,11 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.2: + postcss@8.5.3: dependencies: nanoid: 3.3.8 picocolors: 1.1.1 source-map-js: 1.2.1 - optional: true postgres-array@2.0.0: {} @@ -28486,7 +28656,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.3.5(@microsoft/api-extractor@7.49.1(@types/node@22.10.2))(jiti@2.4.1)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.7.0): + tsup@8.3.5(@microsoft/api-extractor@7.49.1(@types/node@22.10.2))(jiti@2.4.1)(postcss@8.5.3)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.7.0): dependencies: bundle-require: 5.1.0(esbuild@0.24.2) cac: 6.7.14 @@ -28496,7 +28666,7 @@ snapshots: esbuild: 0.24.2 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.1)(postcss@8.5.2)(tsx@4.19.2)(yaml@2.7.0) + postcss-load-config: 6.0.1(jiti@2.4.1)(postcss@8.5.3)(tsx@4.19.2)(yaml@2.7.0) resolve-from: 5.0.0 rollup: 4.32.1 source-map: 0.8.0-beta.0 @@ -28506,7 +28676,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: '@microsoft/api-extractor': 7.49.1(@types/node@22.10.2) - postcss: 8.5.2 + postcss: 8.5.3 typescript: 5.7.2 transitivePeerDependencies: - jiti @@ -28823,6 +28993,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@11.1.0: {} + uuid@3.4.0: {} uuid@7.0.3: {} @@ -28893,6 +29065,27 @@ snapshots: - tsx - yaml + vite-node@3.0.7(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + cac: 6.7.14 + debug: 4.4.0 + es-module-lexer: 1.6.0 + pathe: 2.0.3 + vite: 6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-dts@4.3.0(@types/node@22.10.2)(rollup@4.34.8)(typescript@5.7.2)(vite@6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)): dependencies: '@microsoft/api-extractor': 7.49.1(@types/node@22.10.2) @@ -28931,6 +29124,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@5.1.4(typescript@5.7.2)(vite@6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)): + dependencies: + debug: 4.4.0 + globrex: 0.1.2 + tsconfck: 3.1.4(typescript@5.7.2) + optionalDependencies: + vite: 6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + transitivePeerDependencies: + - supports-color + - typescript + vite@5.4.14(@types/node@22.10.2)(lightningcss@1.27.0)(terser@5.37.0): dependencies: esbuild: 0.21.5 @@ -28956,12 +29160,32 @@ snapshots: tsx: 4.19.2 yaml: 2.7.0 + vite@6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + esbuild: 0.25.0 + postcss: 8.5.3 + rollup: 4.34.8 + optionalDependencies: + '@types/node': 22.10.2 + fsevents: 2.3.3 + jiti: 2.4.1 + lightningcss: 1.27.0 + terser: 5.37.0 + tsx: 4.19.2 + yaml: 2.7.0 + vitest-mock-extended@2.0.2(typescript@5.7.2)(vitest@2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)): dependencies: ts-essentials: 10.0.4(typescript@5.7.2) typescript: 5.7.2 vitest: 2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0) + vitest-mock-extended@2.0.2(typescript@5.7.2)(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)): + dependencies: + ts-essentials: 10.0.4(typescript@5.7.2) + typescript: 5.7.2 + vitest: 3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + vitest@2.1.9(@types/node@22.10.2)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0): dependencies: '@vitest/expect': 2.1.9 @@ -29038,6 +29262,46 @@ snapshots: - tsx - yaml + vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + '@vitest/expect': 3.0.7 + '@vitest/mocker': 3.0.7(vite@6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + '@vitest/pretty-format': 3.0.7 + '@vitest/runner': 3.0.7 + '@vitest/snapshot': 3.0.7 + '@vitest/spy': 3.0.7 + '@vitest/utils': 3.0.7 + chai: 5.2.0 + debug: 4.4.0 + expect-type: 1.1.0 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.8.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 6.2.0(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + vite-node: 3.0.7(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.10.2 + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vlq@1.0.1: {} vm-browserify@1.1.2: {} @@ -29393,6 +29657,10 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zod-openapi@4.2.3(zod@3.24.1): + dependencies: + zod: 3.24.1 + zod-to-json-schema@3.24.1(zod@3.24.1): dependencies: zod: 3.24.1 diff --git a/sonar-project.properties b/sonar-project.properties index 0df026b689..897155f65b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -9,6 +9,8 @@ sonar.exclusions=**/node_modules/**,**/.next/**,**/dist/**,**/build/**,**/*.test sonar.tests=apps/web sonar.test.inclusions=**/*.test.*,**/*.spec.* sonar.javascript.lcov.reportPaths=apps/web/coverage/lcov.info +sonar.coverage.exclusions=**/constants.ts,**/route.ts,**/openapi.ts,**/openapi-document.ts,modules/**/types/**,playwright/**,**/*.test.*,**/*.spec.* +sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,**/openapi.ts,**/openapi-document.ts,modules/**/types/** # TypeScript configuration sonar.typescript.tsconfigPath=apps/web/tsconfig.json diff --git a/turbo.json b/turbo.json index 149f552630..b3dba1dfc9 100644 --- a/turbo.json +++ b/turbo.json @@ -183,7 +183,8 @@ "VERCEL_URL", "VERSION", "WEBAPP_URL", - "UNSPLASH_ACCESS_KEY" + "UNSPLASH_ACCESS_KEY", + "UNKEY_ROOT_KEY" ], "outputs": ["dist/**", ".next/**"] }, diff --git a/vitest.workspace.ts b/vitest.workspace.ts index c1efc9a8b8..6f72511116 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -1 +1 @@ -export default ["packages/*"]; +export default ["packages/*/vite.config.mts", "apps/web/vite.config.mts"];