diff --git a/docker-compose.yml b/docker-compose.yml index 3573148d..c741bc60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,5 @@ -version: "3.2" +version: '3.2' + services: api: build: diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index d6272ae9..95976baf 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -441,4 +441,16 @@ export default class NoteService { return noteHistoryPublic; } + + /** + * Return a sequence of parent notes for the given note id. + * @param noteId - id of the note to get parent structure + * @returns - array of notes that are parent structure of the note + */ + public async getNoteParents(noteId: NoteInternalId): Promise { + const noteIds: NoteInternalId[] = await this.noteRelationsRepository.getNoteParentsIds(noteId); + const noteParents = await this.noteRepository.getNotesByIds(noteIds); + + return noteParents; + } } diff --git a/src/presentation/http/router/note.test.ts b/src/presentation/http/router/note.test.ts index 2187fb53..9f0a9a5b 100644 --- a/src/presentation/http/router/note.test.ts +++ b/src/presentation/http/router/note.test.ts @@ -518,6 +518,152 @@ describe('Note API', () => { expect(response?.json().message).toStrictEqual(expectedMessage); }); + + test('Returns one parents note in case when note has one parent', async () => { + /** Create test user */ + const user = await global.db.insertUser(); + + /** Create access token for the user */ + const accessToken = global.auth(user.id); + + /** Create test note - a parent note */ + const parentNote = await global.db.insertNote({ + creatorId: user.id, + }); + + /** Create test note - a child note */ + const childNote = await global.db.insertNote({ + creatorId: user.id, + }); + + /** Create test note settings */ + await global.db.insertNoteSetting({ + noteId: childNote.id, + isPublic: true, + }); + + /** Create test note relation */ + await global.db.insertNoteRelation({ + parentId: parentNote.id, + noteId: childNote.id, + }); + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${childNote.publicId}`, + }); + + expect(response?.statusCode).toBe(200); + + expect(response?.json()).toMatchObject({ + parents: [ + { + id: parentNote.publicId, + content: parentNote.content, + }, + ], + }); + }); + + test('Returns note parents in correct order in case when parents created in a non-linear order', async () => { + /** Create test user */ + const user = await global.db.insertUser(); + + /** Create access token for the user */ + const accessToken = global.auth(user.id); + + /** Create test note - a grand parent note */ + const firstNote = await global.db.insertNote({ + creatorId: user.id, + }); + + /** Create test note - a parent note */ + const secondNote = await global.db.insertNote({ + creatorId: user.id, + }); + + /** Create test note - a child note */ + const thirdNote = await global.db.insertNote({ + creatorId: user.id, + }); + + /** Create test note settings */ + await global.db.insertNoteSetting({ + noteId: secondNote.id, + isPublic: true, + }); + + /** Create note relation between parent and grandParentNote */ + await global.db.insertNoteRelation({ + parentId: firstNote.id, + noteId: thirdNote.id, + }); + + /** Create test note relation */ + await global.db.insertNoteRelation({ + parentId: thirdNote.id, + noteId: secondNote.id, + }); + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${secondNote.publicId}`, + }); + + expect(response?.statusCode).toBe(200); + + expect(response?.json()).toMatchObject({ + parents: [ + { + id: firstNote.publicId, + content: firstNote.content, + }, + { + id: thirdNote.publicId, + content: thirdNote.content, + }, + ], + }); + }); + + test('Returns empty array in case where there is no relation exist for the note', async () => { + /** Create test user */ + const user = await global.db.insertUser(); + + /** Create access token for the user */ + const accessToken = global.auth(user.id); + + /** Create test note - a child note */ + const note = await global.db.insertNote({ + creatorId: user.id, + }); + + /** Create test note settings */ + await global.db.insertNoteSetting({ + noteId: note.id, + isPublic: true, + }); + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${note.publicId}`, + }); + + expect(response?.statusCode).toBe(200); + + expect(response?.json()).toMatchObject({ + parents: [], + }); + }); }); describe('PATCH note/:notePublicId ', () => { diff --git a/src/presentation/http/router/note.ts b/src/presentation/http/router/note.ts index 6de6b09d..3427890b 100644 --- a/src/presentation/http/router/note.ts +++ b/src/presentation/http/router/note.ts @@ -85,6 +85,7 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don canEdit: boolean; }; tools: EditorTool[]; + parents: NotePublic[]; } | ErrorResponse; }>('/:notePublicId', { config: { @@ -123,6 +124,12 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don $ref: 'EditorToolSchema', }, }, + parents: { + type: 'array', + items: { + $ref: 'NoteSchema', + }, + }, }, }, }, @@ -172,11 +179,18 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don */ const canEdit = memberRole === MemberRole.Write; + const noteParentStructure = await noteService.getNoteParents(noteId); + + const noteParentsPublic = noteParentStructure.map((notes) => { + return definePublicNote(notes); + }); + return reply.send({ note: notePublic, parentNote: parentNote, accessRights: { canEdit: canEdit }, tools: noteTools, + parents: noteParentsPublic, }); }); diff --git a/src/repository/note.repository.ts b/src/repository/note.repository.ts index 0771f8b9..4bfa3aa5 100644 --- a/src/repository/note.repository.ts +++ b/src/repository/note.repository.ts @@ -81,4 +81,13 @@ export default class NoteRepository { public async getNoteListByUserId(id: number, offset: number, limit: number): Promise { return await this.storage.getNoteListByUserId(id, offset, limit); } + + /** + * Get all notes based on their ids + * @param noteIds : list of note ids + * @returns an array of notes + */ + public async getNotesByIds(noteIds: NoteInternalId[]): Promise { + return await this.storage.getNotesByIds(noteIds); + } } diff --git a/src/repository/noteRelations.repository.ts b/src/repository/noteRelations.repository.ts index f31c1a4e..cb740640 100644 --- a/src/repository/noteRelations.repository.ts +++ b/src/repository/noteRelations.repository.ts @@ -67,4 +67,13 @@ export default class NoteRelationsRepository { public async hasRelation(noteId: NoteInternalId): Promise { return await this.storage.hasRelation(noteId); } + + /** + * Get all note parents based on note id + * @param noteId : note id to get all its parents + * @returns an array of note parents ids + */ + public async getNoteParentsIds(noteId: NoteInternalId): Promise { + return await this.storage.getNoteParentsIds(noteId); + } } diff --git a/src/repository/storage/postgres/orm/sequelize/note.ts b/src/repository/storage/postgres/orm/sequelize/note.ts index 4d97591b..c45b3be5 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -1,11 +1,10 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes, ModelStatic, NonAttribute, Sequelize } from 'sequelize'; -import { DataTypes, Model } from 'sequelize'; +import { DataTypes, Model, Op } from 'sequelize'; import type Orm from '@repository/storage/postgres/orm/sequelize/index.js'; import type { Note, NoteCreationAttributes, NoteInternalId, NotePublicId } from '@domain/entities/note.js'; import { UserModel } from '@repository/storage/postgres/orm/sequelize/user.js'; import type { NoteSettingsModel } from './noteSettings.js'; import type { NoteVisitsModel } from './noteVisits.js'; -import { DomainError } from '@domain/entities/DomainError.js'; import type { NoteHistoryModel } from './noteHistory.js'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -233,11 +232,11 @@ export default class NoteSequelizeStorage { */ public async getNoteListByUserId(userId: number, offset: number, limit: number): Promise { if (this.visitsModel === null) { - throw new DomainError('NoteVisit model should be defined'); + throw new Error('NoteStorage: NoteVisit model should be defined'); } if (!this.settingsModel) { - throw new Error('Note settings model not initialized'); + throw new Error('NoteStorage: Note settings model not initialized'); } const reply = await this.model.findAll({ @@ -293,7 +292,7 @@ export default class NoteSequelizeStorage { */ public async getNoteByHostname(hostname: string): Promise { if (!this.settingsModel) { - throw new Error('Note settings model not initialized'); + throw new Error('NoteStorage: Note settings model not initialized'); } /** @@ -324,4 +323,27 @@ export default class NoteSequelizeStorage { }, }); }; + + /** + * Get all notes based on their ids in the same order of passed ids + * @param noteIds - list of note ids + */ + public async getNotesByIds(noteIds: NoteInternalId[]): Promise { + if (noteIds.length === 0) { + return []; + } + + const notes: Note[] = await this.model.findAll({ + where: { + id: { + [Op.in]: noteIds, + }, + }, + order: [ + this.database.literal(`ARRAY_POSITION(ARRAY[${noteIds.map(id => `${id}`).join(',')}], id)`), + ], + }); + + return notes; + } } diff --git a/src/repository/storage/postgres/orm/sequelize/noteRelations.ts b/src/repository/storage/postgres/orm/sequelize/noteRelations.ts index 879ddb98..e7eb9aa8 100644 --- a/src/repository/storage/postgres/orm/sequelize/noteRelations.ts +++ b/src/repository/storage/postgres/orm/sequelize/noteRelations.ts @@ -1,4 +1,5 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes, ModelStatic, Sequelize } from 'sequelize'; +import { QueryTypes } from 'sequelize'; import { Op } from 'sequelize'; import { NoteModel } from '@repository/storage/postgres/orm/sequelize/note.js'; import type Orm from '@repository/storage/postgres/orm/sequelize/index.js'; @@ -209,4 +210,39 @@ export default class NoteRelationsSequelizeStorage { return foundNote !== null; }; + + /** + * Get all parent notes of a note that a user has access to, + * where the user has access to. + * @param noteId - the ID of the note. + */ + public async getNoteParentsIds(noteId: NoteInternalId): Promise { + // Query to get all parent notes of a note. + // The query uses a recursive common table expression (CTE) to get all parent notes of a note. + // It starts from the note with the ID :startNoteId and recursively gets all parent notes. + // It returns a list of note ID and parent ID of the note. + const query = ` + WITH RECURSIVE note_parents AS ( + SELECT np.note_id, np.parent_id + FROM ${String(this.database.literal(this.tableName).val)} np + WHERE np.note_id = :startNoteId + UNION ALL + SELECT nr.note_id, nr.parent_id + FROM ${String(this.database.literal(this.tableName).val)} nr + INNER JOIN note_parents np ON np.parent_id = nr.note_id + ) + SELECT np.note_id AS "noteId", np.parent_id AS "parentId" + FROM note_parents np;`; + + const result = await this.model.sequelize?.query(query, { + replacements: { startNoteId: noteId }, + type: QueryTypes.SELECT, + }); + + let noteParents = (result as { noteId: number; parentId: number }[])?.map(note => note.parentId) ?? []; + + noteParents.reverse(); + + return noteParents; + } } diff --git a/src/repository/storage/postgres/orm/sequelize/teams.ts b/src/repository/storage/postgres/orm/sequelize/teams.ts index 24df0d4c..5dfc0cd5 100644 --- a/src/repository/storage/postgres/orm/sequelize/teams.ts +++ b/src/repository/storage/postgres/orm/sequelize/teams.ts @@ -7,7 +7,6 @@ import { UserModel } from './user.js'; import { MemberRole } from '@domain/entities/team.js'; import type User from '@domain/entities/user.js'; import type { NoteInternalId } from '@domain/entities/note.js'; -import { DomainError } from '@domain/entities/DomainError.js'; /** * Class representing a teams model in database @@ -188,7 +187,7 @@ export default class TeamsSequelizeStorage { */ public async getTeamMembersWithUserInfoByNoteId(noteId: NoteInternalId): Promise { if (!this.userModel) { - throw new DomainError('User model not initialized'); + throw new Error('TeamStorage: User model not defined'); } return await this.model.findAll({