diff --git a/apps/web/app/api/v1/client/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/responses/[responseId]/route.ts index ce97dd73a5..daad1011c2 100644 --- a/apps/web/app/api/v1/client/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/client/responses/[responseId]/route.ts @@ -1,7 +1,8 @@ import { responses } from "@/lib/api/response"; import { transformErrorToDetails } from "@/lib/api/validator"; -import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getResponse, updateResponse } from "@formbricks/lib/services/response"; +import { sendToPipeline } from "@/lib/pipelines"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/errors"; +import { updateResponse } from "@formbricks/lib/services/response"; import { getSurvey } from "@formbricks/lib/services/survey"; import { ZResponseUpdateInput } from "@formbricks/types/v1/responses"; import { NextResponse } from "next/server"; @@ -27,55 +28,54 @@ export async function PUT( ); } - // get current response - const currentResponse = await getResponse(responseId); - - if (!currentResponse) { - return responses.notFoundResponse("Response", responseId, true); + // update response + let response; + try { + response = await updateResponse(responseId, inputValidation.data); + } catch (error) { + if (error instanceof ResourceNotFoundError) { + return responses.notFoundResponse("Response", responseId, true); + } + if (error instanceof InvalidInputError) { + return responses.badRequestResponse(error.message); + } + if (error instanceof DatabaseError) { + return responses.internalServerErrorResponse(error.message); + } } // get survey to get environmentId - const survey = await getSurvey(currentResponse.surveyId); - if (!survey) { - // shouldn't happen as survey relation is required - return responses.notFoundResponse("Survey", currentResponse.surveyId, true); + let survey; + try { + survey = await getSurvey(response.surveyId); + } catch (error) { + if (error instanceof InvalidInputError) { + return responses.badRequestResponse(error.message); + } + if (error instanceof DatabaseError) { + return responses.internalServerErrorResponse(error.message); + } } - const environmentId = survey.environmentId; - - // update response - const response = await updateResponse(responseId, responseUpdate); // send response update to pipeline // don't await to not block the response - fetch(`${WEBAPP_URL}/api/pipeline`, { - method: "POST", - headers: { - "Content-Type": "application/json", + sendToPipeline("responseUpdated", { + environmentId: survey.environmentId, + surveyId: survey.id, + // only send the updated fields + data: { + ...response, + data: inputValidation.data.data, }, - body: JSON.stringify({ - internalSecret: INTERNAL_SECRET, - environmentId, - surveyId: response.surveyId, - event: "responseUpdated", - data: response, - }), }); if (response.finished) { // send response to pipeline // don't await to not block the response - fetch(`${WEBAPP_URL}/api/pipeline`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - internalSecret: INTERNAL_SECRET, - environmentId, - surveyId: response.surveyId, - event: "responseFinished", - data: response, - }), + sendToPipeline("responseFinished", { + environmentId: survey.environmentId, + surveyId: survey.id, + data: response, }); } diff --git a/apps/web/app/api/v1/client/responses/route.ts b/apps/web/app/api/v1/client/responses/route.ts index 792e810e75..1eb52cc3d2 100644 --- a/apps/web/app/api/v1/client/responses/route.ts +++ b/apps/web/app/api/v1/client/responses/route.ts @@ -1,13 +1,14 @@ import { responses } from "@/lib/api/response"; -import { prisma } from "@formbricks/database"; import { transformErrorToDetails } from "@/lib/api/validator"; +import { sendToPipeline } from "@/lib/pipelines"; +import { DatabaseError, InvalidInputError } from "@formbricks/errors"; +import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; import { createResponse } from "@formbricks/lib/services/response"; -import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; import { getSurvey } from "@formbricks/lib/services/survey"; +import { captureTelemetry } from "@formbricks/lib/telemetry"; import { TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses"; import { NextResponse } from "next/server"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; -import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; +import { prisma } from "@formbricks/database"; export async function OPTIONS(): Promise { return responses.successResponse({}, true); @@ -25,25 +26,22 @@ export async function POST(request: Request): Promise { ); } - // check if survey exists - const survey = await getSurvey(responseInput.surveyId); + let survey; - if (!survey) { - return responses.badRequestResponse( - "Linked ressource not found", - { - surveyId: "Survey not found", - }, - true - ); + try { + survey = await getSurvey(responseInput.surveyId); + } catch (error) { + if (error instanceof InvalidInputError) { + return responses.badRequestResponse(error.message); + } else { + return responses.internalServerErrorResponse(error.message); + } } - const environmentId = survey.environmentId; - // prisma call to get the teamId // TODO use services const environment = await prisma.environment.findUnique({ - where: { id: environmentId }, + where: { id: survey.environmentId }, include: { product: { select: { @@ -63,9 +61,8 @@ export async function POST(request: Request): Promise { }); if (!environment) { - throw new Error("Environment not found"); + return responses.internalServerErrorResponse("Environment not found"); } - const { product: { team: { id: teamId, memberships }, @@ -74,39 +71,28 @@ export async function POST(request: Request): Promise { const teamOwnerId = memberships[0]?.userId; - const response = await createResponse(responseInput); + let response; + try { + response = await createResponse(responseInput); + } catch (error) { + if (error instanceof InvalidInputError) { + return responses.badRequestResponse(error.message); + } else { + return responses.internalServerErrorResponse(error.message); + } + } - // send response to pipeline - // don't await to not block the response - fetch(`${WEBAPP_URL}/api/pipeline`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - internalSecret: INTERNAL_SECRET, - environmentId, - surveyId: response.surveyId, - event: "responseCreated", - data: response, - }), + sendToPipeline("responseCreated", { + environmentId: survey.environmentId, + surveyId: response.surveyId, + data: response, }); if (responseInput.finished) { - // send response to pipeline - // don't await to not block the response - fetch(`${WEBAPP_URL}/api/pipeline`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - internalSecret: INTERNAL_SECRET, - environmentId, - surveyId: response.surveyId, - event: "responseFinished", - data: response, - }), + sendToPipeline("responseFinished", { + environmentId: survey.environmentId, + surveyId: response.surveyId, + data: response, }); } diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts index 778aa301ca..16b0a45e78 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts @@ -1,6 +1,6 @@ -import { hashApiKey } from "@/lib/api/apiHelper"; import { responses } from "@/lib/api/response"; -import { prisma } from "@formbricks/database"; +import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey"; +import { deleteWebhook, getWebhook } from "@formbricks/lib/services/webhook"; import { headers } from "next/headers"; export async function GET(_: Request, { params }: { params: { webhookId: string } }) { @@ -8,24 +8,13 @@ export async function GET(_: Request, { params }: { params: { webhookId: string if (!apiKey) { return responses.notAuthenticatedResponse(); } - const apiKeyData = await prisma.apiKey.findUnique({ - where: { - hashedKey: hashApiKey(apiKey), - }, - select: { - environmentId: true, - }, - }); + const apiKeyData = await getApiKeyFromKey(apiKey); if (!apiKeyData) { return responses.notAuthenticatedResponse(); } // add webhook to database - const webhook = await prisma.webhook.findUnique({ - where: { - id: params.webhookId, - }, - }); + const webhook = await getWebhook(params.webhookId); if (!webhook) { return responses.notFoundResponse("Webhook", params.webhookId); } @@ -37,26 +26,17 @@ export async function DELETE(_: Request, { params }: { params: { webhookId: stri if (!apiKey) { return responses.notAuthenticatedResponse(); } - const apiKeyData = await prisma.apiKey.findUnique({ - where: { - hashedKey: hashApiKey(apiKey), - }, - select: { - environmentId: true, - }, - }); + const apiKeyData = await getApiKeyFromKey(apiKey); if (!apiKeyData) { return responses.notAuthenticatedResponse(); } // add webhook to database - const webhook = await prisma.webhook.delete({ - where: { - id: params.webhookId, - }, - }); - if (!webhook) { + try { + const webhook = await deleteWebhook(params.webhookId); + return responses.successResponse(webhook); + } catch (e) { + console.error(e.message); return responses.notFoundResponse("Webhook", params.webhookId); } - return responses.successResponse(webhook); } diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index 2b3c4cc581..8b03dfc85f 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -1,33 +1,32 @@ -import { headers } from "next/headers"; -import { prisma } from "@formbricks/database"; -import { NextResponse } from "next/server"; -import { hashApiKey } from "@/lib/api/apiHelper"; import { responses } from "@/lib/api/response"; +import { transformErrorToDetails } from "@/lib/api/validator"; +import { DatabaseError, InvalidInputError } from "@formbricks/errors"; +import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey"; +import { createWebhook, getWebhooks } from "@formbricks/lib/services/webhook"; +import { ZWebhookInput } from "@formbricks/types/v1/webhooks"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; export async function GET() { const apiKey = headers().get("x-api-key"); if (!apiKey) { return responses.notAuthenticatedResponse(); } - const apiKeyData = await prisma.apiKey.findUnique({ - where: { - hashedKey: hashApiKey(apiKey), - }, - select: { - environmentId: true, - }, - }); + const apiKeyData = await getApiKeyFromKey(apiKey); if (!apiKeyData) { return responses.notAuthenticatedResponse(); } - // add webhook to database - const webhooks = await prisma.webhook.findMany({ - where: { - environmentId: apiKeyData.environmentId, - }, - }); - return NextResponse.json({ data: webhooks }); + // get webhooks from database + try { + const webhooks = await getWebhooks(apiKeyData.environmentId); + return NextResponse.json({ data: webhooks }); + } catch (error) { + if (error instanceof DatabaseError) { + return responses.badRequestResponse(error.message); + } + throw error; + } } export async function POST(request: Request) { @@ -35,37 +34,32 @@ export async function POST(request: Request) { if (!apiKey) { return responses.notAuthenticatedResponse(); } - const apiKeyData = await prisma.apiKey.findUnique({ - where: { - hashedKey: hashApiKey(apiKey), - }, - select: { - environmentId: true, - }, - }); + const apiKeyData = await getApiKeyFromKey(apiKey); if (!apiKeyData) { return responses.notAuthenticatedResponse(); } - const { url, trigger } = await request.json(); - if (!url) { - return responses.missingFieldResponse("url"); - } + const webhookInput = await request.json(); + const inputValidation = ZWebhookInput.safeParse(webhookInput); - if (!trigger) { - return responses.missingFieldResponse("trigger"); + if (!inputValidation.success) { + return responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error), + true + ); } // add webhook to database - const webhook = await prisma.webhook.create({ - data: { - url, - triggers: [trigger], - environment: { - connect: { - id: apiKeyData.environmentId, - }, - }, - }, - }); - return responses.successResponse(webhook); + try { + const webhook = await createWebhook(apiKeyData.environmentId, inputValidation.data); + return responses.successResponse(webhook); + } catch (error) { + if (error instanceof InvalidInputError) { + return responses.badRequestResponse(error.message); + } + if (error instanceof DatabaseError) { + return responses.internalServerErrorResponse(error.message); + } + throw error; + } } diff --git a/apps/web/lib/api/response.ts b/apps/web/lib/api/response.ts index a75928dd2e..a48a9282ac 100644 --- a/apps/web/lib/api/response.ts +++ b/apps/web/lib/api/response.ts @@ -112,8 +112,22 @@ const successResponse = (data: Object, cors: boolean = false) => } ); +const internalServerErrorResponse = (message: string, cors: boolean = false) => + NextResponse.json( + { + code: "internal_server_error", + message, + details: {}, + } as ApiErrorResponse, + { + status: 500, + ...(cors && { headers: corsHeaders }), + } + ); + export const responses = { badRequestResponse, + internalServerErrorResponse, missingFieldResponse, methodNotAllowedResponse, notAuthenticatedResponse, diff --git a/apps/web/lib/pipelines.ts b/apps/web/lib/pipelines.ts new file mode 100644 index 0000000000..fc9ffb0d26 --- /dev/null +++ b/apps/web/lib/pipelines.ts @@ -0,0 +1,19 @@ +import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; + +export async function sendToPipeline(event, data) { + return fetch(`${WEBAPP_URL}/api/pipeline`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + internalSecret: INTERNAL_SECRET, + environmentId: data.environmentId, + surveyId: data.surveyId, + event, + data, + }), + }).catch((error) => { + console.error(`Error sending event to pipeline: ${error}`); + }); +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index ed7e34f066..604faf605f 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -12,7 +12,13 @@ const nextConfig = { experimental: { serverActions: true, }, - transpilePackages: ["@formbricks/database", "@formbricks/ee", "@formbricks/ui", "@formbricks/lib"], + transpilePackages: [ + "@formbricks/database", + "@formbricks/ee", + "@formbricks/ui", + "@formbricks/lib", + "@formbricks/errors", + ], images: { remotePatterns: [ { diff --git a/apps/web/package.json b/apps/web/package.json index 9e49a0cbb8..225a6ab1d6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@formbricks/ee": "workspace:*", + "@formbricks/errors": "workspace:*", "@formbricks/js": "workspace:*", "@formbricks/lib": "workspace:*", "@formbricks/ui": "workspace:*", diff --git a/packages/errors/src/errors.ts b/packages/errors/src/errors.ts new file mode 100644 index 0000000000..5b4c815eb4 --- /dev/null +++ b/packages/errors/src/errors.ts @@ -0,0 +1,83 @@ +class ResourceNotFoundError extends Error { + statusCode = 404; + constructor(resource: string, id: string) { + super(`${resource} with ID ${id} not found`); + this.name = "ResourceNotFoundError"; + } +} + +class InvalidInputError extends Error { + statusCode = 400; + constructor(message: string) { + super(message); + this.name = "InvalidInputError"; + } +} + +class ValidationError extends Error { + statusCode = 400; + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +class DatabaseError extends Error { + statusCode = 500; + constructor(message: string) { + super(message); + this.name = "DatabaseError"; + } +} + +class UniqueConstraintError extends Error { + statusCode = 409; + constructor(message: string) { + super(message); + this.name = "UniqueConstraintError"; + } +} + +class ForeignKeyConstraintError extends Error { + statusCode = 409; + constructor(message: string) { + super(message); + this.name = "ForeignKeyConstraintError"; + } +} + +class OperationNotAllowedError extends Error { + statusCode = 403; + constructor(message: string) { + super(message); + this.name = "OperationNotAllowedError"; + } +} + +class AuthenticationError extends Error { + statusCode = 401; + constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + } +} + +class AuthorizationError extends Error { + statusCode = 403; + constructor(message: string) { + super(message); + this.name = "AuthorizationError"; + } +} + +export { + ResourceNotFoundError, + InvalidInputError, + ValidationError, + DatabaseError, + UniqueConstraintError, + ForeignKeyConstraintError, + OperationNotAllowedError, + AuthenticationError, + AuthorizationError, +}; diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 192a290220..8891e961b2 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -1,2 +1,3 @@ export * from "./functions"; export * from "./result"; +export * from "./errors"; diff --git a/packages/lib/crypto.ts b/packages/lib/crypto.ts new file mode 100644 index 0000000000..2984d1dc85 --- /dev/null +++ b/packages/lib/crypto.ts @@ -0,0 +1,3 @@ +import { createHash } from "crypto"; + +export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex"); diff --git a/packages/lib/package.json b/packages/lib/package.json index b1d681e17b..4a5deb0f1c 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -12,11 +12,12 @@ "lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json" }, "dependencies": { - "@formbricks/database": "*" + "@formbricks/database": "*", + "@formbricks/types": "*", + "@formbricks/errors": "*" }, "devDependencies": { "@formbricks/tsconfig": "*", - "@formbricks/types": "*", "eslint": "^8.41.0", "eslint-config-formbricks": "workspace:*", "typescript": "^5.0.4" diff --git a/packages/lib/services/apiKey.ts b/packages/lib/services/apiKey.ts new file mode 100644 index 0000000000..f671e73b9a --- /dev/null +++ b/packages/lib/services/apiKey.ts @@ -0,0 +1,57 @@ +import { prisma } from "@formbricks/database"; +import { TApiKey } from "@formbricks/types/v1/apiKeys"; +import { Prisma } from "@prisma/client"; +import { getHash } from "../crypto"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/errors"; + +export const getApiKey = async (apiKey: string): Promise => { + if (!apiKey) { + throw new InvalidInputError("API key cannot be null or undefined."); + } + + try { + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + hashedKey: getHash(apiKey), + }, + }); + + if (!apiKeyData) { + throw new ResourceNotFoundError("API Key", apiKey); + } + + return apiKeyData; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}; + +export const getApiKeyFromKey = async (apiKey: string): Promise => { + if (!apiKey) { + throw new InvalidInputError("API key cannot be null or undefined."); + } + + try { + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + hashedKey: getHash(apiKey), + }, + }); + + if (!apiKeyData) { + throw new ResourceNotFoundError("API Key", apiKey); + } + + return apiKeyData; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}; diff --git a/packages/lib/services/person.ts b/packages/lib/services/person.ts index 314a745f4e..d910523bd6 100644 --- a/packages/lib/services/person.ts +++ b/packages/lib/services/person.ts @@ -1,5 +1,7 @@ -import { TPerson } from "@formbricks/types/v1/people"; import { prisma } from "@formbricks/database"; +import { TPerson } from "@formbricks/types/v1/people"; +import { Prisma } from "@prisma/client"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; type TransformPersonInput = { id: string; @@ -33,24 +35,36 @@ export const transformPrismaPerson = (person: TransformPersonInput | null): Tran }; export const getPerson = async (personId: string): Promise => { - const personPrisma = await prisma.person.findUnique({ - where: { - id: personId, - }, - include: { - attributes: { - include: { - attributeClass: { - select: { - name: true, + try { + const personPrisma = await prisma.person.findUnique({ + where: { + id: personId, + }, + include: { + attributes: { + include: { + attributeClass: { + select: { + name: true, + }, }, }, }, }, - }, - }); + }); - const person = transformPrismaPerson(personPrisma); + if (!personPrisma) { + throw new ResourceNotFoundError("Person", personId); + } - return person; + const person = transformPrismaPerson(personPrisma); + + return person; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } }; diff --git a/packages/lib/services/response.ts b/packages/lib/services/response.ts index 007f48480a..ce00d50a5a 100644 --- a/packages/lib/services/response.ts +++ b/packages/lib/services/response.ts @@ -1,159 +1,179 @@ import { prisma } from "@formbricks/database"; import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses"; +import { Prisma } from "@prisma/client"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; import { transformPrismaPerson } from "./person"; export const createResponse = async (responseInput: TResponseInput): Promise => { - const responsePrisma = await prisma.response.create({ - data: { - survey: { - connect: { - id: responseInput.surveyId, - }, - }, - finished: responseInput.finished, - data: responseInput.data, - ...(responseInput.personId && { - person: { + try { + const responsePrisma = await prisma.response.create({ + data: { + survey: { connect: { - id: responseInput.personId, + id: responseInput.surveyId, }, }, - }), - }, - select: { - id: true, - createdAt: true, - updatedAt: true, - surveyId: true, - finished: true, - data: true, - person: { - select: { - id: true, - attributes: { - select: { - value: true, - attributeClass: { - select: { - name: true, + finished: responseInput.finished, + data: responseInput.data, + ...(responseInput.personId && { + person: { + connect: { + id: responseInput.personId, + }, + }, + }), + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + surveyId: true, + finished: true, + data: true, + person: { + select: { + id: true, + attributes: { + select: { + value: true, + attributeClass: { + select: { + name: true, + }, }, }, }, }, }, }, - }, - }); + }); - const response: TResponse = { - ...responsePrisma, - createdAt: responsePrisma.createdAt.toISOString(), - updatedAt: responsePrisma.updatedAt.toISOString(), - person: transformPrismaPerson(responsePrisma.person), - }; + const response: TResponse = { + ...responsePrisma, + person: transformPrismaPerson(responsePrisma.person), + }; - return response; + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } }; export const getResponse = async (responseId: string): Promise => { - const responsePrisma = await prisma.response.findUnique({ - where: { - id: responseId, - }, - select: { - id: true, - createdAt: true, - updatedAt: true, - surveyId: true, - finished: true, - data: true, - person: { - select: { - id: true, - attributes: { - select: { - value: true, - attributeClass: { - select: { - name: true, + try { + const responsePrisma = await prisma.response.findUnique({ + where: { + id: responseId, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + surveyId: true, + finished: true, + data: true, + person: { + select: { + id: true, + attributes: { + select: { + value: true, + attributeClass: { + select: { + name: true, + }, }, }, }, }, }, }, - }, - }); + }); - if (!responsePrisma) { - return null; + if (!responsePrisma) { + throw new ResourceNotFoundError("Response", responseId); + } + + const response: TResponse = { + ...responsePrisma, + person: transformPrismaPerson(responsePrisma.person), + }; + + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; } - - const response: TResponse = { - ...responsePrisma, - createdAt: responsePrisma.createdAt.toISOString(), - updatedAt: responsePrisma.updatedAt.toISOString(), - person: transformPrismaPerson(responsePrisma.person), - }; - - return response; }; export const updateResponse = async ( responseId: string, responseInput: TResponseUpdateInput ): Promise => { - const currentResponse = await getResponse(responseId); + try { + const currentResponse = await getResponse(responseId); - if (!currentResponse) { - throw new Error("Response not found"); - } + if (!currentResponse) { + throw new ResourceNotFoundError("Response", responseId); + } - // merge data object - const data = { - ...currentResponse.data, - ...responseInput.data, - }; + // merge data object + const data = { + ...currentResponse.data, + ...responseInput.data, + }; - const responsePrisma = await prisma.response.update({ - where: { - id: responseId, - }, - data: { - finished: responseInput.finished, - data, - }, - select: { - id: true, - createdAt: true, - updatedAt: true, - surveyId: true, - finished: true, - data: true, - person: { - select: { - id: true, - attributes: { - select: { - value: true, - attributeClass: { - select: { - name: true, + const responsePrisma = await prisma.response.update({ + where: { + id: responseId, + }, + data: { + finished: responseInput.finished, + data, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + surveyId: true, + finished: true, + data: true, + person: { + select: { + id: true, + attributes: { + select: { + value: true, + attributeClass: { + select: { + name: true, + }, }, }, }, }, }, }, - }, - }); + }); - const response: TResponse = { - ...responsePrisma, - createdAt: responsePrisma.createdAt.toISOString(), - updatedAt: responsePrisma.updatedAt.toISOString(), - person: transformPrismaPerson(responsePrisma.person), - }; + const response: TResponse = { + ...responsePrisma, + person: transformPrismaPerson(responsePrisma.person), + }; - return response; + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } }; diff --git a/packages/lib/services/survey.ts b/packages/lib/services/survey.ts index 0b5a00614b..a78a76b596 100644 --- a/packages/lib/services/survey.ts +++ b/packages/lib/services/survey.ts @@ -1,38 +1,50 @@ import { prisma } from "@formbricks/database"; +import { ValidationError } from "@formbricks/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; import { TSurvey, ZSurvey } from "@formbricks/types/v1/surveys"; +import { Prisma } from "@prisma/client"; export const getSurvey = async (surveyId: string): Promise => { - const surveyPrisma = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - include: { - triggers: { - select: { - eventClass: { - select: { - id: true, - name: true, - description: true, - type: true, - noCodeConfig: true, + let surveyPrisma; + try { + surveyPrisma = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + include: { + triggers: { + select: { + eventClass: { + select: { + id: true, + name: true, + description: true, + type: true, + noCodeConfig: true, + }, }, }, }, - }, - attributeFilters: { - select: { - id: true, - attributeClassId: true, - condition: true, - value: true, + attributeFilters: { + select: { + id: true, + attributeClassId: true, + condition: true, + value: true, + }, }, }, - }, - }); + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } if (!surveyPrisma) { - return null; + throw new ResourceNotFoundError("Survey", surveyId); } const numDisplays = await prisma.display.count({ @@ -53,8 +65,6 @@ export const getSurvey = async (surveyId: string): Promise => { const transformedSurvey = { ...surveyPrisma, - createdAt: surveyPrisma.createdAt.toISOString(), - updatedAt: surveyPrisma.updatedAt.toISOString(), triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass), analytics: { numDisplays, @@ -62,7 +72,10 @@ export const getSurvey = async (surveyId: string): Promise => { }, }; - const survey = ZSurvey.parse(transformedSurvey); - - return survey; + try { + const survey = ZSurvey.parse(transformedSurvey); + return survey; + } catch (error) { + throw new ValidationError("Data validation of survey failed"); + } }; diff --git a/packages/lib/services/webhook.ts b/packages/lib/services/webhook.ts new file mode 100644 index 0000000000..8135c75c10 --- /dev/null +++ b/packages/lib/services/webhook.ts @@ -0,0 +1,77 @@ +import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@prisma/client"; +import { ResourceNotFoundError, DatabaseError, InvalidInputError } from "@formbricks/errors"; + +export const getWebhooks = async (environmentId: string): Promise => { + try { + return await prisma.webhook.findMany({ + where: { + environmentId: environmentId, + }, + }); + } catch (error) { + throw new DatabaseError(`Database error when fetching webhooks for environment ${environmentId}`); + } +}; + +export const getWebhook = async (id: string): Promise => { + try { + const webhook = await prisma.webhook.findUnique({ + where: { + id, + }, + }); + if (!webhook) { + throw new ResourceNotFoundError("Webhook", id); + } + return webhook; + } catch (error) { + if (!(error instanceof ResourceNotFoundError)) { + throw new DatabaseError(`Database error when fetching webhook with ID ${id}`); + } + throw error; + } +}; + +export const createWebhook = async ( + environmentId: string, + webhookInput: TWebhookInput +): Promise => { + try { + if (!webhookInput.url || !webhookInput.trigger) { + throw new InvalidInputError("Missing URL or trigger in webhook input"); + } + return await prisma.webhook.create({ + data: { + url: webhookInput.url, + triggers: [webhookInput.trigger], + environment: { + connect: { + id: environmentId, + }, + }, + }, + }); + } catch (error) { + if (!(error instanceof InvalidInputError)) { + throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`); + } + throw error; + } +}; + +export const deleteWebhook = async (id: string): Promise => { + try { + return await prisma.webhook.delete({ + where: { + id, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + throw new ResourceNotFoundError("Webhook", id); + } + throw new DatabaseError(`Database error when deleting webhook with ID ${id}`); + } +}; diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 6f2692e3b1..4f24eab9ea 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "@formbricks/tsconfig/js-library.json", "include": ["."], - "exclude": ["dist", "build", "node_modules"] + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@prisma/client/*": ["@formbricks/database/client/*"] + } + } } diff --git a/packages/types/v1/apiKeys.ts b/packages/types/v1/apiKeys.ts new file mode 100644 index 0000000000..eb3f9cd57d --- /dev/null +++ b/packages/types/v1/apiKeys.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const ZApiKey = z.object({ + id: z.string().cuid2(), + createdAt: z.date(), + lastUsedAt: z.date().nullable(), + label: z.string().nullable(), + hashedKey: z.string(), + environmentId: z.string().cuid2(), +}); + +export type TApiKey = z.infer; diff --git a/packages/types/v1/responses.ts b/packages/types/v1/responses.ts index da66ef1508..3304cb642c 100644 --- a/packages/types/v1/responses.ts +++ b/packages/types/v1/responses.ts @@ -6,8 +6,8 @@ export type TResponseData = z.infer; const ZResponse = z.object({ id: z.string().cuid2(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + createdAt: z.date(), + updatedAt: z.date(), surveyId: z.string().cuid2(), person: z .object({ diff --git a/packages/types/v1/surveys.ts b/packages/types/v1/surveys.ts index e92da1cf2f..fccee27211 100644 --- a/packages/types/v1/surveys.ts +++ b/packages/types/v1/surveys.ts @@ -178,9 +178,9 @@ export const ZSurveyAttributeFilter = z.object({ }); export const ZSurvey = z.object({ - id: z.string(), - createdAt: z.string(), - updatedAt: z.string(), + id: z.string().cuid2(), + createdAt: z.date(), + updatedAt: z.date(), name: z.string(), type: z.union([z.literal("web"), z.literal("email"), z.literal("link"), z.literal("mobile")]), environmentId: z.string(), diff --git a/packages/types/v1/webhooks.ts b/packages/types/v1/webhooks.ts new file mode 100644 index 0000000000..37b276a746 --- /dev/null +++ b/packages/types/v1/webhooks.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const ZWebhookTrigger = z.enum(["responseFinished", "responseCreated", "responseUpdated"]); + +export const ZWebhook = z.object({ + id: z.string().cuid2(), + createdAt: z.date(), + updatedAt: z.date(), + url: z.string().url(), + environmentId: z.string().cuid2(), + triggers: z.array(ZWebhookTrigger), +}); + +export type TWebhook = z.infer; + +export const ZWebhookInput = z.object({ + url: z.string().url(), + trigger: ZWebhookTrigger, +}); + +export type TWebhookInput = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb8277aa1b..9bddefa98f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: '@formbricks/ee': specifier: workspace:* version: link:../../packages/ee + '@formbricks/errors': + specifier: workspace:* + version: link:../../packages/errors '@formbricks/js': specifier: workspace:* version: link:../../packages/js @@ -575,13 +578,16 @@ importers: '@formbricks/database': specifier: '*' version: link:../database + '@formbricks/errors': + specifier: '*' + version: link:../errors + '@formbricks/types': + specifier: '*' + version: link:../types devDependencies: '@formbricks/tsconfig': specifier: '*' version: link:../tsconfig - '@formbricks/types': - specifier: '*' - version: link:../types eslint: specifier: ^8.41.0 version: 8.41.0