diff --git a/src/domain/entities/DomainError.ts b/src/domain/entities/DomainError.ts new file mode 100644 index 00000000..4d9c6781 --- /dev/null +++ b/src/domain/entities/DomainError.ts @@ -0,0 +1,11 @@ +/** + * DomainError entity — manually thrown exception provided by business logic + */ +export class DomainError extends Error { + /** + * @param message - description of why the error was thrown + */ + constructor(message: string) { + super(message); + } +} diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 97e6fe07..30f927f2 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -1,6 +1,7 @@ import type { Note, NoteInternalId, NotePublicId } from '@domain/entities/note.js'; import type NoteRepository from '@repository/note.repository.js'; import { createPublicId } from '@infrastructure/utils/id.js'; +import { DomainError } from '@domain/entities/DomainError'; /** * Note service @@ -54,7 +55,7 @@ export default class NoteService { const updatedNote = await this.repository.updateNoteContentById(id, content); if (updatedNote === null) { - throw new Error(`Note with id ${id} was not updated`); + throw new DomainError(`Note with id ${id} was not updated`); } return updatedNote; @@ -69,7 +70,7 @@ export default class NoteService { const note = await this.repository.getNoteById(id); if (note === null) { - throw new Error(`Note with id ${id} was not found`); + throw new DomainError(`Note with id ${id} was not found`); } return note; @@ -84,7 +85,7 @@ export default class NoteService { const note = await this.repository.getNoteByPublicId(publicId); if (note === null) { - throw new Error(`Note with public id ${publicId} was not found`); + throw new DomainError(`Note with public id ${publicId} was not found`); } return note; diff --git a/src/domain/service/noteSettings.ts b/src/domain/service/noteSettings.ts index 18a3c32d..475194d5 100644 --- a/src/domain/service/noteSettings.ts +++ b/src/domain/service/noteSettings.ts @@ -7,6 +7,7 @@ import type { Team, TeamMember, TeamMemberCreationAttributes } from '@domain/ent import { MemberRole } from '@domain/entities/team.js'; import type User from '@domain/entities/user.js'; import { createInvitationHash } from '@infrastructure/utils/invitationHash.js'; +import { DomainError } from '@domain/entities/DomainError'; /** * Service responsible for Note Settings @@ -44,7 +45,7 @@ export default class NoteSettingsService { * Check if invitation hash is valid */ if (noteSettings === null) { - throw new Error(`Wrong invitation`); + throw new DomainError(`Wrong invitation`); } /** @@ -53,7 +54,7 @@ export default class NoteSettingsService { const isUserTeamMember = await this.teamRepository.isUserInTeam(userId, noteSettings.noteId); if (isUserTeamMember) { - throw new Error(`User already in team`); + throw new DomainError(`User already in team`); } return await this.teamRepository.createTeamMembership({ @@ -69,7 +70,13 @@ export default class NoteSettingsService { * @param id - note internal id */ public async getNoteSettingsByNoteId(id: NoteInternalId): Promise { - return await this.noteSettingsRepository.getNoteSettingsByNoteId(id); + const settings = await this.noteSettingsRepository.getNoteSettingsByNoteId(id); + + if (settings === null) { + throw new DomainError(`Note settings not found`); + } + + return settings; } /** @@ -97,6 +104,10 @@ export default class NoteSettingsService { public async patchNoteSettingsByNoteId(noteId: NoteInternalId, data: Partial): Promise { const noteSettings = await this.noteSettingsRepository.getNoteSettingsByNoteId(noteId); + if (noteSettings === null) { + throw new DomainError(`Note settings not found`); + } + return await this.noteSettingsRepository.patchNoteSettingsById(noteSettings.id, data); } diff --git a/src/domain/service/user.ts b/src/domain/service/user.ts index b54a0ea1..3186b445 100644 --- a/src/domain/service/user.ts +++ b/src/domain/service/user.ts @@ -3,6 +3,7 @@ import { Provider } from '@repository/user.repository.js'; import type User from '@domain/entities/user.js'; import type EditorTool from '@domain/entities/editorTools'; import type { SharedDomainMethods } from './shared/index.js'; +import { DomainError } from '@domain/entities/DomainError.js'; export { Provider @@ -57,7 +58,7 @@ export default class UserService { const user = await this.getUserById(userId); if (user === null) { - throw new Error('User not found'); + throw new DomainError('User not found'); } const userToolsIds = user.editorTools ?? []; diff --git a/src/presentation/http/decorators/domainError.ts b/src/presentation/http/decorators/domainError.ts new file mode 100644 index 00000000..05df3ff5 --- /dev/null +++ b/src/presentation/http/decorators/domainError.ts @@ -0,0 +1,30 @@ +import { StatusCodes } from 'http-status-codes'; +import type { FastifyReply } from 'fastify'; + +/** + * Custom method for replying with information that business logic dismissed the request for some reason + * + * Send this error when a domain-level error is thrown + * + * @example + * + * try { + * service.someAction() + * } catch (error: Error) { + * if (error instanceof DomainError) { + * reply.domainError(error.message); + * return; + * } + * throw error; + * } + * + * @param message - Optional message to send. If not specified, default message will be sent + */ +export default async function domainError(this: FastifyReply, message = 'Domain level error'): Promise { + await this + .code(StatusCodes.BAD_REQUEST) + .type('application/json') + .send({ + message, + }); +} \ No newline at end of file diff --git a/src/presentation/http/decorators/index.ts b/src/presentation/http/decorators/index.ts index 982f4a9d..3504dfb8 100644 --- a/src/presentation/http/decorators/index.ts +++ b/src/presentation/http/decorators/index.ts @@ -2,10 +2,12 @@ import notFound from './notFound.js'; import forbidden from './forbidden.js'; import unauthorized from './unauthorized.js'; import notAcceptable from './notAcceptable.js'; +import domainError from './domainError.js'; export { notFound, forbidden, unauthorized, - notAcceptable + notAcceptable, + domainError }; diff --git a/src/presentation/http/fastify.d.ts b/src/presentation/http/fastify.d.ts index ec723764..b7fc28d5 100644 --- a/src/presentation/http/fastify.d.ts +++ b/src/presentation/http/fastify.d.ts @@ -125,5 +125,26 @@ declare module 'fastify' { * @param message - Optional message to send. If not specified, default message will be sent */ notAcceptable: (message?: string) => Promise; + + /** + * Custom method for replying with information that business logic dismissed the request for some reason + * + * Send this error when a domain-level error is thrown + * + * @example + * + * try { + * service.someAction() + * } catch (error: Error) { + * if (error instanceof DomainError) { + * reply.domainError(error.message); + * return; + * } + * throw error; + * } + * + * @param message - Optional message to send. If not specified, default message will be sent + */ + domainError: (message?: string) => Promise; } } diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index fbb5b20c..63819664 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -10,7 +10,7 @@ import fastifySwagger from '@fastify/swagger'; import fastifySwaggerUI from '@fastify/swagger-ui'; import addUserIdResolver from '@presentation/http/middlewares/common/userIdResolver.js'; import cookie from '@fastify/cookie'; -import { notFound, forbidden, unauthorized, notAcceptable } from './decorators/index.js'; +import { notFound, forbidden, unauthorized, notAcceptable, domainError } from './decorators/index.js'; import NoteRouter from '@presentation/http/router/note.js'; import OauthRouter from '@presentation/http/router/oauth.js'; import AuthRouter from '@presentation/http/router/auth.js'; @@ -26,6 +26,7 @@ import NoteListRouter from '@presentation/http/router/noteList.js'; import { EditorToolSchema } from './schema/EditorTool.js'; import JoinRouter from '@presentation/http/router/join.js'; import { JoinSchemaParams, JoinSchemaResponse } from './schema/Join.js'; +import { DomainError } from '@domain/entities/DomainError.js'; const appServerLogger = getLogger('appServer'); @@ -81,6 +82,8 @@ export default class HttpApi implements Api { this.addPoliciesCheckHook(); await this.addApiRoutes(domainServices); + + this.domainErrorHandler(); } @@ -287,6 +290,7 @@ export default class HttpApi implements Api { this.server?.decorateReply('forbidden', forbidden); this.server?.decorateReply('unauthorized', unauthorized); this.server?.decorateReply('notAcceptable', notAcceptable); + this.server?.decorateReply('domainError', domainError); } /** @@ -329,5 +333,20 @@ export default class HttpApi implements Api { }); }); } + + /** + * Domain error handler + */ + private domainErrorHandler(): void { + this.server?.setErrorHandler(function (error, request, reply) { + /** + * If we have an error that occurs in the domain-level we reply it with special format + */ + if (error instanceof DomainError) { + this.log.error(error); + void reply.domainError(error.message); + } + }); + } } diff --git a/src/repository/noteSettings.repository.ts b/src/repository/noteSettings.repository.ts index 9e35b923..23285665 100644 --- a/src/repository/noteSettings.repository.ts +++ b/src/repository/noteSettings.repository.ts @@ -47,7 +47,7 @@ export default class NoteSettingsRepository { * @param id - note id * @returns found note settings */ - public async getNoteSettingsByNoteId(id: NoteInternalId): Promise { + public async getNoteSettingsByNoteId(id: NoteInternalId): Promise { return await this.storage.getNoteSettingsByNoteId(id); } diff --git a/src/repository/storage/postgres/orm/sequelize/noteSettings.ts b/src/repository/storage/postgres/orm/sequelize/noteSettings.ts index 49ddd984..89c44b35 100644 --- a/src/repository/storage/postgres/orm/sequelize/noteSettings.ts +++ b/src/repository/storage/postgres/orm/sequelize/noteSettings.ts @@ -147,7 +147,7 @@ export default class NoteSettingsSequelizeStorage { * @param noteId - note id * @returns { Promise } - note settings */ - public async getNoteSettingsByNoteId(noteId: NoteSettings['noteId']): Promise { + public async getNoteSettingsByNoteId(noteId: NoteSettings['noteId']): Promise { const settings = await this.model.findOne({ where: { noteId: noteId, @@ -155,10 +155,7 @@ export default class NoteSettingsSequelizeStorage { }); if (!settings) { - /** - * TODO: improve exceptions - */ - throw new Error('Note settings not found'); + return null; } return settings;