diff --git a/src/domain/index.ts b/src/domain/index.ts index 81d7766b..c6bf75c1 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -7,7 +7,7 @@ import UserService from '@domain/service/user.js'; import AIService from './service/ai.js'; import EditorToolsService from '@domain/service/editorTools.js'; import FileUploaderService from './service/fileUploader.service.js'; -import NoteVisitsService from '@domain/service/noteVisits.js'; +import NoteVisitsService from './service/noteVisits.js'; /** * Interface for initiated services @@ -49,9 +49,9 @@ export interface DomainServices { fileUploaderService: FileUploaderService, /** - * Note Visits service instance + * Note visits service instance */ - noteVisitsService: NoteVisitsService + noteVisitsService: NoteVisitsService; } /** @@ -61,16 +61,14 @@ export interface DomainServices { * @param appConfig - app config */ export function init(repositories: Repositories, appConfig: AppConfig): DomainServices { - const noteService = new NoteService(repositories.noteRepository, repositories.noteRelationsRepository); - + const noteService = new NoteService(repositories.noteRepository, repositories.noteRelationsRepository, repositories.noteVisitsRepository); + const noteVisitsService = new NoteVisitsService(repositories.noteVisitsRepository); const authService = new AuthService( appConfig.auth.accessSecret, appConfig.auth.accessExpiresIn, appConfig.auth.refreshExpiresIn, repositories.userSessionRepository ); - const noteVisitsService = new NoteVisitsService(repositories.noteVisitsRepository); - const editorToolsService = new EditorToolsService(repositories.editorToolsRepository); const sharedServices = { diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 0a2fcda8..fda9ec92 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -1,5 +1,6 @@ import type { Note, NoteInternalId, NotePublicId } from '@domain/entities/note.js'; import type NoteRepository from '@repository/note.repository.js'; +import type NoteVisitsRepository from '@repository/noteVisits.repository.js'; import { createPublicId } from '@infrastructure/utils/id.js'; import { DomainError } from '@domain/entities/DomainError.js'; import type NoteRelationsRepository from '@repository/noteRelations.repository.js'; @@ -20,6 +21,11 @@ export default class NoteService { */ public noteRelationsRepository: NoteRelationsRepository; + /** + * Note visits repository + */ + public noteVisitsRepository: NoteVisitsRepository; + /** * Number of the notes to be displayed on one page * it is used to calculate offset and limit for getting notes that the user has recently opened @@ -31,10 +37,12 @@ export default class NoteService { * * @param noteRepository - note repository * @param noteRelationsRepository - note relationship repository + * @param noteVisitsRepository - note visits repository */ - constructor(noteRepository: NoteRepository, noteRelationsRepository: NoteRelationsRepository) { + constructor(noteRepository: NoteRepository, noteRelationsRepository: NoteRelationsRepository, noteVisitsRepository: NoteVisitsRepository) { this.noteRepository = noteRepository; this.noteRelationsRepository = noteRelationsRepository; + this.noteVisitsRepository = noteVisitsRepository; } /** diff --git a/src/domain/service/noteVisits.ts b/src/domain/service/noteVisits.ts index e1a35c59..3794a186 100644 --- a/src/domain/service/noteVisits.ts +++ b/src/domain/service/noteVisits.ts @@ -31,4 +31,13 @@ export default class NoteVisitsService { public async saveVisit(noteId: NoteInternalId, userId: User['id']): Promise { return await this.noteVisitsRepository.saveVisit(noteId, userId); }; + + /** + * Deletes all visits of the note when a note is deleted + * + * @param noteId - note internal id + */ + public async deleteNoteVisits(noteId: NoteInternalId): Promise { + return await this.noteVisitsRepository.deleteNoteVisits(noteId); + } } \ No newline at end of file diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index 03031896..5bdc5493 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -196,6 +196,7 @@ export default class HttpApi implements Api { prefix: '/note', noteService: domainServices.noteService, noteSettingsService: domainServices.noteSettingsService, + noteVisitsService: domainServices.noteVisitsService, }); await this.server?.register(NoteListRouter, { diff --git a/src/presentation/http/router/note.ts b/src/presentation/http/router/note.ts index ed51af19..96d65fb5 100644 --- a/src/presentation/http/router/note.ts +++ b/src/presentation/http/router/note.ts @@ -8,6 +8,7 @@ import useNoteSettingsResolver from '../middlewares/noteSettings/useNoteSettings import useMemberRoleResolver from '../middlewares/noteSettings/useMemberRoleResolver.js'; import { MemberRole } from '@domain/entities/team.js'; import { type NotePublic, definePublicNote } from '@domain/entities/notePublic.js'; +import type NoteVisitsService from '@domain/service/noteVisits.js'; /** * Interface for the note router. @@ -22,6 +23,11 @@ interface NoteRouterOptions { * Note Settings service instance */ noteSettingsService: NoteSettingsService, + + /** + * Note visits service instance + */ + noteVisitsService: NoteVisitsService; } /** @@ -37,6 +43,7 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don */ const noteService = opts.noteService; const noteSettingsService = opts.noteSettingsService; + const noteVisitsService = opts.noteVisitsService; /** * Prepare note id resolver middleware @@ -111,7 +118,9 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don ], }, async (request, reply) => { const { note } = request; + const noteId = request.note?.id as number; const { memberRole } = request; + const { userId } = request; /** * Check if note exists @@ -119,6 +128,16 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don if (note === null) { return reply.notFound('Note not found'); } + + /** + * Check if user is authorized + * + * @todo use event bus to save note visits + */ + if (userId !== null) { + await noteVisitsService.saveVisit(noteId, userId); + } + const parentId = await noteService.getParentNoteIdByNoteId(note.id); const parentNote = parentId !== null ? definePublicNote(await noteService.getNoteById(parentId)) : undefined; @@ -367,6 +386,13 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don const isDeleted = await noteService.unlinkParent(noteId); + /** + * Delete all visits of the note + * + * @todo use event bus to delete note visits + */ + await noteVisitsService.deleteNoteVisits(noteId); + /** * Check if parent relation was successfully deleted */ @@ -417,7 +443,7 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don }, }, async (request, reply) => { const params = request.params; - + const { userId } = request; const note = await noteService.getNoteByHostname(params.hostname); /** @@ -427,6 +453,15 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don return reply.notFound('Note not found'); } + /** + * Save note visit if user is authorized + * + * @todo use event bus to save note visits + */ + if (userId !== null) { + await noteVisitsService.saveVisit(note.id, userId); + } + /** * By default, unauthorized user can not edit the note */ diff --git a/src/repository/noteVisits.repository.ts b/src/repository/noteVisits.repository.ts index 859ba04f..74896f18 100644 --- a/src/repository/noteVisits.repository.ts +++ b/src/repository/noteVisits.repository.ts @@ -27,4 +27,13 @@ export default class NoteVisitsRepository { public async saveVisit(noteId: NoteInternalId, userId: User['id']): Promise { return await this.storage.saveVisit(noteId, userId); } + + /** + * Deletes all visits of the note when a note is deleted + * + * @param noteId - note internal id + */ + public async deleteNoteVisits(noteId: NoteInternalId): Promise { + return await this.storage.deleteNoteVisits(noteId); + } } \ No newline at end of file diff --git a/src/repository/storage/postgres/orm/sequelize/noteVisits.ts b/src/repository/storage/postgres/orm/sequelize/noteVisits.ts index eba9c348..6bc9ad21 100644 --- a/src/repository/storage/postgres/orm/sequelize/noteVisits.ts +++ b/src/repository/storage/postgres/orm/sequelize/noteVisits.ts @@ -1,6 +1,7 @@ import type NoteVisit from '@domain/entities/noteVisit.js'; import type User from '@domain/entities/user.js'; import type { Sequelize, InferAttributes, InferCreationAttributes, CreationOptional, ModelStatic } from 'sequelize'; +import { literal } from 'sequelize'; import { Model, DataTypes } from 'sequelize'; import type Orm from '@repository/storage/postgres/orm/sequelize/index.js'; import { NoteModel } from './note.js'; @@ -117,18 +118,58 @@ export default class NoteVisitsSequelizeStorage { * If user has already visited note, then existing record will be updated * If user is visiting note for the first time, new record will be created */ - /* eslint-disable-next-line */ - const [recentVisit, _] = await this.model.upsert({ - noteId, - userId, - }, { - conflictWhere: { + const existingVisit = await this.model.findOne({ + where: { noteId, userId, }, - returning: true, }); - return recentVisit; + let updatedVisits: NoteVisit[]; + let _; + + if (existingVisit === null) { + return await this.model.create({ + noteId, + userId, + /** + * we should pass to model datatype respectfully to declared in NoteVisitsModel class + * if we will pass just 'CLOCK_TIMESTAMP()' it will be treated by orm just like a string, that is why we should use literal + * but model wants string, this is why we use this cast + */ + visitedAt: literal('CLOCK_TIMESTAMP()') as unknown as string, + }); + } else { + [_, updatedVisits] = await this.model.update({ + visitedAt: literal('CLOCK_TIMESTAMP()') as unknown as string, + }, { + where: { + noteId, + userId, + }, + returning: true, + }); + } + + return updatedVisits[0]; + } + + /** + * Deletes all visits of the note when a note is deleted + * + * @param noteId - note internal id + */ + public async deleteNoteVisits(noteId: NoteInternalId): Promise { + const deletedNoteVisits = await this.model.destroy({ + where: { + noteId, + }, + }); + + if (deletedNoteVisits) { + return true; + } + + return false; } } \ No newline at end of file diff --git a/src/tests/utils/database-helpers.ts b/src/tests/utils/database-helpers.ts index 330025e5..eff127e8 100644 --- a/src/tests/utils/database-helpers.ts +++ b/src/tests/utils/database-helpers.ts @@ -242,10 +242,10 @@ export default class DatabaseHelpers { * * @param visit object which contain all info about noteVisit (visitedAt is optional) * - * if no visitedAt passed, then visited_at would have CURRENT_DATE value + * if no visitedAt passed, then visited_at would have CLOCK_TIMESTAMP() value */ public async insertNoteVisit(visit: NoteVisitCreationAttributes): Promise { - const visitedAt = visit.visitedAt ?? 'NOW()'; + const visitedAt = visit.visitedAt ?? 'CLOCK_TIMESTAMP()'; const [results, _] = await this.orm.connection.query(`INSERT INTO public.note_visits ("user_id", "note_id", "visited_at")