diff --git a/.env b/.env index 32f917e..e88e6fc 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ DATABASE_URL=mysql://root:root@localhost:3306/dashboard_template -JWT_SECRET= \ No newline at end of file +JWT_SECRET= + +ERROR_LOG_PATH=./logs \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 8228dda..cffb24b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/env.ts b/env.ts index 572376e..a7863d8 100644 --- a/env.ts +++ b/env.ts @@ -3,7 +3,8 @@ const envVariables = z.object({ DATABASE_URL: z.string(), JWT_SECRET: z.string(), WS_PORT: z.string().optional(), - WS_HOST: z.string().optional() + WS_HOST: z.string().optional(), + ERROR_LOG_PATH: z.string() }); envVariables.parse(process.env); diff --git a/logs/.gitignore b/logs/.gitignore new file mode 100644 index 0000000..bf0824e --- /dev/null +++ b/logs/.gitignore @@ -0,0 +1 @@ +*.log \ No newline at end of file diff --git a/package.json b/package.json index 7d57c0e..d826b40 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bcrypt": "^5.1.1", "client-only": "^0.0.1", "clsx": "^2.1.0", + "date-fns": "^3.3.1", "jsonwebtoken": "^9.0.2", "mantine-form-zod-resolver": "^1.1.0", "next": "14.1.0", diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts new file mode 100644 index 0000000..95ee487 --- /dev/null +++ b/src/app/api/login/route.ts @@ -0,0 +1,38 @@ +import BaseError from "@/core/error/BaseError"; +import handleCatchApi from "@/core/utils/handleCatchApi"; +import AuthError from "@/modules/auth/error/AuthError"; +import signInSchema from "@/modules/auth/formSchemas/signInSchema"; +import signIn from "@/modules/auth/services/signIn"; +import getTokenFromHeaders from "@/modules/auth/utils/getTokenFromHeaders"; +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { json } from "stream/consumers"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + try { + if (request.headers.get("Content-Type") !== "application/json") + throw new BaseError({ + errorCode: "UNSUPPORTED_CONTENT_TYPE", + message: + "This content type is not supported. Please use application/json instead", + statusCode: 400 + }); + const data = signInSchema.safeParse(await request.json()); + + if (!data.success){ + throw new AuthError({ + errorCode: "INVALID_CREDENTIALS", + message: "Email or Password does not match", + statusCode: 401 + }) + } + + const result = await signIn(data.data) + + return NextResponse.json(result); + } catch (e) { + return handleCatchApi(e) + } +} diff --git a/src/core/error/BaseError.ts b/src/core/error/BaseError.ts index 9c82306..6e0fefc 100644 --- a/src/core/error/BaseError.ts +++ b/src/core/error/BaseError.ts @@ -1,20 +1,25 @@ -export const BaseErrorCodes = ["UNKOWN_ERROR"] as const; +import logger from "../logger/Logger"; + +export const BaseErrorCodes = ["UNKNOWN_ERROR", "UNSUPPORTED_CONTENT_TYPE"] as const; interface ErrorOptions { message?: string; errorCode: (typeof BaseErrorCodes)[number] | (string & {}); + statusCode?: number } class BaseError extends Error { public readonly errorCode: (typeof BaseErrorCodes)[number] | (string & {}); + public readonly statusCode: number; constructor(options: ErrorOptions) { super(options.message ?? "Undetermined Error"); - this.errorCode = options.errorCode ?? "UNKOWN_ERROR"; + this.errorCode = options.errorCode ?? "UNKNOWN_ERROR"; + this.statusCode = options.statusCode ?? 500; Object.setPrototypeOf(this, new.target.prototype); - console.error("error:", options) + this.saveToLog(); } getActionResponseObject() { @@ -26,6 +31,23 @@ class BaseError extends Error { }, } as const; } + + getRestApiResponseObject(){ + return { + message: this.message, + errorCode: this.errorCode + } + } + + saveToLog() { + const excludedErrorCodes: string[] = []; + + if (excludedErrorCodes.includes(this.errorCode)) { + return; + } + + logger.error(JSON.stringify({errorCode: this.errorCode, message: this.message, stack: this.stack})) + } } export default BaseError; diff --git a/src/core/logger/Logger.ts b/src/core/logger/Logger.ts new file mode 100644 index 0000000..d682d3a --- /dev/null +++ b/src/core/logger/Logger.ts @@ -0,0 +1,44 @@ +import { appendFileSync } from "node:fs"; +import { format } from 'date-fns'; + +class Logger { + + logDirectory: string; + + readonly severityLevels = { + CRITICAL: 'CRITICAL', + ERROR: 'ERROR', + WARNING: 'WARNING', + }; + + constructor(){ + this.logDirectory = `${process.env.ERROR_LOG_PATH}` + } + + getLogFileName(isError = false) { + // Use a different naming convention for error logs if needed + const suffix = isError ? '-error' : ''; + return `${format(new Date(), 'yyyy-MM-dd')}${suffix}.log`; + } + + log(message: string, level = 'info') { + const timestamp = format(new Date(), 'yyyy-MM-dd HH:mm:ss'); + const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`; + const isError = level === this.severityLevels.ERROR || level === this.severityLevels.CRITICAL || level === this.severityLevels.WARNING; + const logFilePath = `${this.logDirectory}/${this.getLogFileName(isError)}`; + + appendFileSync(logFilePath, logMessage); + } + + error(message: string, severity = 'ERROR') { + // Ensure the severity level is valid; default to 'ERROR' if not + if (!Object.values(this.severityLevels).includes(severity)) { + severity = this.severityLevels.ERROR; + } + this.log(message, severity); + } +} + +const logger = new Logger(); + +export default logger; diff --git a/src/core/utils/handleCatchApi.ts b/src/core/utils/handleCatchApi.ts new file mode 100644 index 0000000..76352ab --- /dev/null +++ b/src/core/utils/handleCatchApi.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import BaseError from "../error/BaseError"; + +export default function handleCatchApi(e: unknown): NextResponse { + if (e instanceof BaseError) { + return NextResponse.json({ + code: e.errorCode, + message: e.message, + }, {status: e.statusCode}); + } + if (e instanceof Error) { + return NextResponse.json({ + code: "GENERAL_ERROR", + message: e.message, + }, {status: 500}); + } + + return NextResponse.json({ + code: "GENERAL_ERROR", + message: "Unexpected", + }, { status: 500 }); +} diff --git a/src/modules/auth/error/AuthError.ts b/src/modules/auth/error/AuthError.ts index beee2b2..1d4b585 100644 --- a/src/modules/auth/error/AuthError.ts +++ b/src/modules/auth/error/AuthError.ts @@ -7,11 +7,13 @@ export const AuthErrorCodes = [ "INVALID_JWT_TOKEN", "JWT_SECRET_EMPTY", "USER_ALREADY_EXISTS", + "ALREADY_LOGGED_IN" ] as const; interface AuthErrorOptions { message?: string; errorCode: (typeof AuthErrorCodes)[number] | (string & {}); + statusCode?: number; } export default class AuthError extends BaseError { @@ -21,6 +23,7 @@ export default class AuthError extends BaseError { super({ errorCode: options.errorCode, message: options.message, + statusCode: options.statusCode, }); this.errorCode = options.errorCode; diff --git a/src/modules/auth/formSchemas/signInSchema.ts b/src/modules/auth/formSchemas/signInSchema.ts new file mode 100644 index 0000000..c129677 --- /dev/null +++ b/src/modules/auth/formSchemas/signInSchema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +const signInSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export default signInSchema; \ No newline at end of file diff --git a/src/modules/auth/utils/getTokenFromHeaders.ts b/src/modules/auth/utils/getTokenFromHeaders.ts new file mode 100644 index 0000000..51cf8bc --- /dev/null +++ b/src/modules/auth/utils/getTokenFromHeaders.ts @@ -0,0 +1,10 @@ +export default function getTokenFromHeaders(headers: Headers) { + const authorizationHeader = headers.get('authorization'); + if (authorizationHeader) { + const parts = authorizationHeader.split(' '); + if (parts.length === 2 && parts[0] === 'Bearer') { + return parts[1]; + } + } + return null; +}