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/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 76fc3d7d71..1fb614440b 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..3e80453b9a --- /dev/null +++ b/packages/lib/services/apiKey.ts @@ -0,0 +1,19 @@ +import { prisma } from "@formbricks/database"; +import { getHash } from "../crypto"; +import { TApiKey } from "@formbricks/types/v1/apiKeys"; + +export const getApiKey = async (apiKey: string): Promise => { + return await prisma.apiKey.findUnique({ + where: { + hashedKey: getHash(apiKey), + }, + }); +}; + +export const getApiKeyFromKey = async (apiKey: string): Promise => { + return await prisma.apiKey.findUnique({ + where: { + hashedKey: getHash(apiKey), + }, + }); +}; diff --git a/packages/lib/services/response.ts b/packages/lib/services/response.ts index 007f48480a..7cee5a186d 100644 --- a/packages/lib/services/response.ts +++ b/packages/lib/services/response.ts @@ -47,8 +47,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise const response: TResponse = { ...responsePrisma, - createdAt: responsePrisma.createdAt.toISOString(), - updatedAt: responsePrisma.updatedAt.toISOString(), person: transformPrismaPerson(responsePrisma.person), }; @@ -150,8 +146,6 @@ export const updateResponse = async ( const response: TResponse = { ...responsePrisma, - createdAt: responsePrisma.createdAt.toISOString(), - updatedAt: responsePrisma.updatedAt.toISOString(), person: transformPrismaPerson(responsePrisma.person), }; 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 d73221673e..c1774cb3e0 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 9b401e98c8..049ba4b2de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,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 @@ -569,13 +572,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