diff --git a/src/domain/entities/NoteHierarchy.ts b/src/domain/entities/NoteHierarchy.ts new file mode 100644 index 00000000..8ada950f --- /dev/null +++ b/src/domain/entities/NoteHierarchy.ts @@ -0,0 +1,23 @@ +import type { NoteContent, NotePublicId } from './note.js'; + +/** + * Note Tree entity + */ +export interface NoteHierarchy { + + /** + * public note id + */ + id: NotePublicId; + + /** + * note content + */ + content: NoteContent; + + /** + * child notes + */ + childNotes: NoteHierarchy[] | null; + +} diff --git a/src/domain/entities/note.ts b/src/domain/entities/note.ts index b393a680..2af128e9 100644 --- a/src/domain/entities/note.ts +++ b/src/domain/entities/note.ts @@ -26,6 +26,18 @@ export type ToolUsedInNoteContent = { id: EditorTool['id']; }; +/** + * NoteContent + */ +export type NoteContent = { + blocks: Array<{ + id: string; + type: string; + data: unknown; + tunes?: { [name: string]: unknown }; + }>; +}; + /** * Note entity */ @@ -43,14 +55,7 @@ export interface Note { /** * Note content */ - content: { - blocks: Array<{ - id: string; - type: string; - data: unknown; - tunes?: { [name: string]: unknown }; - }>; - }; + content: NoteContent; /** * Note creator id diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 95976baf..111b98fd 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -9,6 +9,7 @@ import type User from '@domain/entities/user.js'; import type { NoteList } from '@domain/entities/noteList.js'; import type NoteHistoryRepository from '@repository/noteHistory.repository.js'; import type { NoteHistoryMeta, NoteHistoryRecord, NoteHistoryPublic } from '@domain/entities/noteHistory.js'; +import type { NoteHierarchy } from '@domain/entities/NoteHierarchy.js'; /** * Note service @@ -453,4 +454,20 @@ export default class NoteService { return noteParents; } + + /** + * Reutrn a tree structure of notes with childNotes for the given note id + * @param noteId - id of the note to get structure + * @returns - Object of notes. + */ + public async getNoteHierarchy(noteId: NoteInternalId): Promise { + const ultimateParent = await this.noteRelationsRepository.getUltimateParentNoteId(noteId); + + // If there is no ultimate parent, the provided noteId is the ultimate parent + const rootNoteId = ultimateParent ?? noteId; + + const noteHierarchy = await this.noteRepository.getNoteHierarchyByNoteId(rootNoteId); + + return noteHierarchy; + } } diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index 31c1046a..6e5cb37a 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -33,6 +33,7 @@ import { DomainError } from '@domain/entities/DomainError.js'; import UploadRouter from './router/upload.js'; import { ajvFilePlugin } from '@fastify/multipart'; import { UploadSchema } from './schema/Upload.js'; +import { NoteHierarchySchema } from './schema/NoteHierarchy.js'; const appServerLogger = getLogger('appServer'); @@ -300,6 +301,7 @@ export default class HttpApi implements Api { this.server?.addSchema(JoinSchemaResponse); this.server?.addSchema(OauthSchema); this.server?.addSchema(UploadSchema); + this.server?.addSchema(NoteHierarchySchema); } /** diff --git a/src/presentation/http/router/note.test.ts b/src/presentation/http/router/note.test.ts index 4b5ad2b9..2d3ad260 100644 --- a/src/presentation/http/router/note.test.ts +++ b/src/presentation/http/router/note.test.ts @@ -1,6 +1,7 @@ import { MemberRole } from '@domain/entities/team.js'; import { describe, test, expect, beforeEach } from 'vitest'; import type User from '@domain/entities/user.js'; +import type { Note } from '@domain/entities/note.js'; describe('Note API', () => { /** @@ -2272,4 +2273,97 @@ describe('Note API', () => { expect(response?.json().message).toBe('Note not found'); }); }); + + describe('GET /note/note-hierarchy/:noteId', () => { + let accessToken = ''; + let user: User; + + beforeEach(async () => { + /** create test user */ + user = await global.db.insertUser(); + + accessToken = global.auth(user.id); + }); + + test.each([ + // Test case 1: No parent or child + { + description: 'Should get note hierarchy with no parent or child when noteId passed has no relations', + setup: async () => { + const note = await global.db.insertNote({ creatorId: user.id }); + + await global.db.insertNoteSetting({ + noteId: note.id, + isPublic: true, + }); + + return { + note: note, + childNote: null, + }; + }, + + expected: (note: Note, childNote: Note | null) => ({ + id: note.publicId, + content: note.content, + childNotes: childNote, + }), + }, + + // Test case 2: With child + { + description: 'Should get note hierarchy with child when noteId passed has relations', + setup: async () => { + const childNote = await global.db.insertNote({ creatorId: user.id }); + const parentNote = await global.db.insertNote({ creatorId: user.id }); + + await global.db.insertNoteSetting({ + noteId: childNote.id, + isPublic: true, + }); + await global.db.insertNoteSetting({ + noteId: parentNote.id, + isPublic: true, + }); + await global.db.insertNoteRelation({ + noteId: childNote.id, + parentId: parentNote.id, + }); + + return { + note: parentNote, + childNote: childNote, + }; + }, + expected: (note: Note, childNote: Note | null) => ({ + id: note.publicId, + content: note.content, + childNotes: [ + { + id: childNote?.publicId, + content: childNote?.content, + childNotes: null, + }, + ], + }), + }, + ])('$description', async ({ setup, expected }) => { + // Setup the test data + const { note, childNote } = await setup(); + + // Make the API request + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/note-hierarchy/${note.publicId}`, + }); + + // Verify the response + expect(response?.json().noteHierarchy).toStrictEqual( + expected(note, childNote) + ); + }); + }); }); diff --git a/src/presentation/http/router/note.ts b/src/presentation/http/router/note.ts index 3427890b..f6cb0492 100644 --- a/src/presentation/http/router/note.ts +++ b/src/presentation/http/router/note.ts @@ -12,6 +12,7 @@ import type NoteVisitsService from '@domain/service/noteVisits.js'; import type EditorToolsService from '@domain/service/editorTools.js'; import type EditorTool from '@domain/entities/editorTools.js'; import type { NoteHistoryMeta, NoteHistoryPublic, NoteHistoryRecord } from '@domain/entities/noteHistory.js'; +import type { NoteHierarchy } from '@domain/entities/NoteHierarchy.js'; /** * Interface for the note router. @@ -141,7 +142,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; @@ -774,6 +777,58 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don }); }); + fastify.get<{ + Params: { + notePublicId: NotePublicId; + }; + Reply: { + noteHierarchy: NoteHierarchy | null; + } | ErrorResponse; + }>('/note-hierarchy/:notePublicId', { + config: { + policy: [ + 'notePublicOrUserInTeam', + ], + }, + schema: { + params: { + notePublicId: { + $ref: 'NoteSchema#/properties/id', + }, + }, + response: { + '2xx': { + type: 'object', + properties: { + noteHierarchy: { + $ref: 'NoteHierarchySchema#', + }, + }, + }, + }, + }, + preHandler: [ + noteResolver, + noteSettingsResolver, + memberRoleResolver, + ], + }, async (request, reply) => { + const noteId = request.note?.id as number; + + /** + * Check if note exists + */ + if (noteId === null) { + return reply.notAcceptable('Note not found'); + } + + const noteHierarchy = await noteService.getNoteHierarchy(noteId); + + return reply.send({ + noteHierarchy: noteHierarchy, + }); + }); + done(); }; diff --git a/src/presentation/http/schema/NoteHierarchy.ts b/src/presentation/http/schema/NoteHierarchy.ts new file mode 100644 index 00000000..84b27fd5 --- /dev/null +++ b/src/presentation/http/schema/NoteHierarchy.ts @@ -0,0 +1,30 @@ +export const NoteHierarchySchema = { + $id: 'NoteHierarchySchema', + properties: { + id: { + type: 'string', + pattern: '[a-zA-Z0-9-_]+', + maxLength: 10, + minLength: 10, + }, + content: { + type: 'object', + properties: { + time: { + type: 'number', + }, + blocks: { + type: 'array', + }, + version: { + type: 'string', + }, + }, + }, + childNotes: { + type: 'array', + items: { $ref: 'NoteHierarchySchema#' }, + nullable: true, + }, + }, +}; diff --git a/src/repository/note.repository.ts b/src/repository/note.repository.ts index 4bfa3aa5..a56df5d0 100644 --- a/src/repository/note.repository.ts +++ b/src/repository/note.repository.ts @@ -1,4 +1,5 @@ import type { Note, NoteCreationAttributes, NoteInternalId, NotePublicId } from '@domain/entities/note.js'; +import type { NoteHierarchy } from '@domain/entities/NoteHierarchy.js'; import type NoteStorage from '@repository/storage/note.storage.js'; /** @@ -90,4 +91,13 @@ export default class NoteRepository { public async getNotesByIds(noteIds: NoteInternalId[]): Promise { return await this.storage.getNotesByIds(noteIds); } + + /** + * Gets the Note tree by note id + * @param noteId - note id + * @returns NoteHierarchy structure + */ + public async getNoteHierarchyByNoteId(noteId: NoteInternalId): Promise { + return await this.storage.getNoteHierarchybyNoteId(noteId); + } } diff --git a/src/repository/noteRelations.repository.ts b/src/repository/noteRelations.repository.ts index cb740640..69f30779 100644 --- a/src/repository/noteRelations.repository.ts +++ b/src/repository/noteRelations.repository.ts @@ -76,4 +76,13 @@ export default class NoteRelationsRepository { public async getNoteParentsIds(noteId: NoteInternalId): Promise { return await this.storage.getNoteParentsIds(noteId); } + + /** + * Get the ultimate parent of a note with note id + * @param noteId - note id to get ultimate parent + * @returns - note id of the ultimate parent + */ + public async getUltimateParentNoteId(noteId: NoteInternalId): Promise { + return await this.storage.getUltimateParentByNoteId(noteId); + } } diff --git a/src/repository/storage/postgres/orm/sequelize/note.ts b/src/repository/storage/postgres/orm/sequelize/note.ts index c45b3be5..b621ffc9 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -1,11 +1,12 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes, ModelStatic, NonAttribute, Sequelize } from 'sequelize'; -import { DataTypes, Model, Op } from 'sequelize'; +import { DataTypes, Model, Op, QueryTypes } 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 type { Note, NoteContent, 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 type { NoteHistoryModel } from './noteHistory.js'; +import type { NoteHierarchy } from '@domain/entities/NoteHierarchy.js'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -346,4 +347,87 @@ export default class NoteSequelizeStorage { return notes; } + + /** + * Creates a tree of notes + * @param noteId - public note id + * @returns NoteHierarchy + */ + public async getNoteHierarchybyNoteId(noteId: NoteInternalId): Promise { + // Fetch all notes and relations in a recursive query + const query = ` + WITH RECURSIVE note_tree AS ( + SELECT + n.id AS noteId, + n.content, + n.public_id, + nr.parent_id + FROM ${String(this.database.literal(this.tableName).val)} n + LEFT JOIN ${String(this.database.literal('note_relations').val)} nr ON n.id = nr.note_id + WHERE n.id = :startNoteId + + UNION ALL + + SELECT + n.id AS noteId, + n.content, + n.public_id, + nr.parent_id + FROM ${String(this.database.literal(this.tableName).val)} n + INNER JOIN ${String(this.database.literal('note_relations').val)} nr ON n.id = nr.note_id + INNER JOIN note_tree nt ON nr.parent_id = nt.noteId + ) + SELECT * FROM note_tree; + `; + + const result = await this.model.sequelize?.query(query, { + replacements: { startNoteId: noteId }, + type: QueryTypes.SELECT, + }); + + if (!result || result.length === 0) { + return null; // No data found + } + + type NoteRow = { + noteid: NoteInternalId; + public_id: NotePublicId; + content: NoteContent; + parent_id: NoteInternalId | null; + }; + + const notes = result as NoteRow[]; + + const notesMap = new Map(); + + let root: NoteHierarchy | null = null; + + // Step 1: Parse and initialize all notes + notes.forEach((note) => { + notesMap.set(note.noteid, { + id: note.public_id, + content: note.content, + childNotes: null, + }); + }); + + // Step 2: Build hierarchy + notes.forEach((note) => { + if (note.parent_id === null) { + root = notesMap.get(note.noteid) ?? null; + } else { + const parent = notesMap.get(note.parent_id); + + if (parent) { + // Initialize childNotes as an array if it's null + if (parent.childNotes === null) { + parent.childNotes = []; + } + parent.childNotes?.push(notesMap.get(note.noteid)!); + } + } + }); + + return root; + } } diff --git a/src/repository/storage/postgres/orm/sequelize/noteRelations.ts b/src/repository/storage/postgres/orm/sequelize/noteRelations.ts index e7eb9aa8..82380ea4 100644 --- a/src/repository/storage/postgres/orm/sequelize/noteRelations.ts +++ b/src/repository/storage/postgres/orm/sequelize/noteRelations.ts @@ -245,4 +245,34 @@ export default class NoteRelationsSequelizeStorage { return noteParents; } + + /** + * Gets the ultimate parent noteId or `null` if none exists + * @param noteId - the ID of note + */ + public async getUltimateParentByNoteId(noteId: NoteInternalId): Promise { + 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.parent_id AS "parentId" + FROM note_parents np + WHERE np.parent_id IS NOT NULL + ORDER BY np.parent_id ASC + LIMIT 1;`; + + const result = await this.model.sequelize?.query(query, { + replacements: { startNoteId: noteId }, + type: QueryTypes.SELECT, + }); + let ultimateParent = (result as { parentId: number }[])[0]?.parentId ?? null; + + return ultimateParent; + } }