Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Note tree presentation feature #290

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions src/domain/entities/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/domain/entities/noteTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { NoteContent, NotePublicId } from './note.js';

export interface NoteTree {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add comment for the whole structure


/**
* public note id
*/
id: NotePublicId;

/**
* note content
*/
content: NoteContent;

/**
* child notes
*/
childNotes: NoteTree[] | null;

}
17 changes: 17 additions & 0 deletions src/domain/service/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { NoteTree } from '@domain/entities/noteTree.js';

/**
* Note service
Expand Down Expand Up @@ -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<NoteTree | null> {
const ultimateParent = await this.noteRelationsRepository.getUltimateParent(noteId);

// If there is no ultimate parent, the provided noteId is the ultimate parent
const rootNoteId = ultimateParent ?? noteId;

const noteTree = await this.noteRepository.getNoteTreeByNoteId(rootNoteId);

return noteTree;
}
}
2 changes: 2 additions & 0 deletions src/presentation/http/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { NoteTreeSchema } from './schema/NoteTree.js';

const appServerLogger = getLogger('appServer');

Expand Down Expand Up @@ -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(NoteTreeSchema);
}

/**
Expand Down
58 changes: 58 additions & 0 deletions src/presentation/http/router/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ 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 { NoteTree } from '@domain/entities/noteTree.js';
import logger from '@infrastructure/logging/index.js';

/**
* Interface for the note router.
Expand Down Expand Up @@ -140,8 +142,14 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
memberRoleResolver,
],
}, async (request, reply) => {
logger.warn(request);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove logs please

const { note } = request;

logger.warn(note);

const noteId = request.note?.id as number;

logger.warn(noteId);
const { memberRole } = request;
const { userId } = request;

Expand Down Expand Up @@ -774,6 +782,56 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
});
});

fastify.get<{
Params: {
notePublicId: NotePublicId;
};
Reply: {
notehierarchy: NoteTree | null;
} | ErrorResponse;
}>('/notehierarchy/:notePublicId', {
config: {
policy: [
'authRequired',
],
},
schema: {
params: {
notePublicId: {
$ref: 'NoteSchema#/properties/id',
},
},
response: {
'2xx': {
type: 'object',
properties: {
notehierarchy: {
$ref: 'NoteTreeSchema#',
},
},
},
},
},
preHandler: [
noteResolver,
],
}, async (request, reply) => {
const noteId = request?.note?.id as number;

/**
* Check if note exists
*/
if (noteId === null) {
return reply.notFound('Note not found');
}

const noteHierarchy = await noteService.getNoteHierarchy(noteId);

return reply.send({
notehierarchy: noteHierarchy,
});
});

done();
};

Expand Down
30 changes: 30 additions & 0 deletions src/presentation/http/schema/NoteTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export const NoteTreeSchema = {
$id: 'NoteTreeSchema',
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: 'NoteTreeSchema#' },
nullable: true,
},
},
};
10 changes: 10 additions & 0 deletions src/repository/note.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Note, NoteCreationAttributes, NoteInternalId, NotePublicId } from '@domain/entities/note.js';
import type { NoteTree } from '@domain/entities/noteTree.js';
import type NoteStorage from '@repository/storage/note.storage.js';

/**
Expand Down Expand Up @@ -90,4 +91,13 @@ export default class NoteRepository {
public async getNotesByIds(noteIds: NoteInternalId[]): Promise<Note[]> {
return await this.storage.getNotesByIds(noteIds);
}

/**
* Gets the Note tree by note id
* @param noteId - note id
* @returns NoteTree structure
*/
public async getNoteTreeByNoteId(noteId: NoteInternalId): Promise<NoteTree | null> {
return await this.storage.getNoteTreebyNoteId(noteId);
}
}
9 changes: 9 additions & 0 deletions src/repository/noteRelations.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,13 @@ export default class NoteRelationsRepository {
public async getNoteParentsIds(noteId: NoteInternalId): Promise<NoteInternalId[]> {
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 getUltimateParent(noteId: NoteInternalId): Promise<NoteInternalId | null> {
return await this.storage.getUltimateParentByNoteId(noteId);
}
}
84 changes: 82 additions & 2 deletions src/repository/storage/postgres/orm/sequelize/note.ts
Original file line number Diff line number Diff line change
@@ -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 { NoteTree } from '@domain/entities/noteTree.js';

/* eslint-disable @typescript-eslint/naming-convention */

Expand Down Expand Up @@ -346,4 +347,83 @@ export default class NoteSequelizeStorage {

return notes;
}

/**
* Creates a tree of notes
* @param noteId - public note id
* @returns NoteTree
*/
public async getNoteTreebyNoteId(noteId: NoteInternalId): Promise<NoteTree | null> {
// 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<NoteInternalId, NoteTree>();

let root: NoteTree | null = null;

// Step 1: Parse and initialize all notes
notes.forEach((note) => {
notesMap.set(note.noteid, {
id: note.public_id,
content: note.content,
childNotes: [],
});
});

// 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) {
parent.childNotes?.push(notesMap.get(note.noteid)!);
}
}
});

return root;
}
}
30 changes: 30 additions & 0 deletions src/repository/storage/postgres/orm/sequelize/noteRelations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,34 @@ export default class NoteRelationsSequelizeStorage {

return noteParents;
}

/**
* Get ultimate parent noteId by noteId
* @param noteId - the ID of note
*/
public async getUltimateParentByNoteId(noteId: NoteInternalId): Promise<NoteInternalId | null> {
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;
}
}
Loading