From d3b322fe9ff483446e00fbbb2dcfaabb8f68f438 Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Wed, 16 Sep 2026 15:48:21 +0300 Subject: [PATCH 1/2] feat: add sidebarPosition field in note-settings --- .../0036-note-settings@add-sidebar-position.sql | 10 ++++++++++ src/domain/entities/noteSettings.ts | 15 +++++++++++++-- src/presentation/http/router/noteSettings.test.ts | 3 +++ src/presentation/http/router/noteSettings.ts | 8 ++++++-- src/presentation/http/schema/NoteSettings.ts | 5 +++++ .../postgres/orm/sequelize/noteSettings.ts | 10 ++++++++++ src/tests/utils/database-helpers.ts | 4 +++- 7 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 migrations/tenant/0036-note-settings@add-sidebar-position.sql diff --git a/migrations/tenant/0036-note-settings@add-sidebar-position.sql b/migrations/tenant/0036-note-settings@add-sidebar-position.sql new file mode 100644 index 00000000..deee8289 --- /dev/null +++ b/migrations/tenant/0036-note-settings@add-sidebar-position.sql @@ -0,0 +1,10 @@ +-- Create "sidebar_position" column in note_settings table +DO $$ +BEGIN + IF NOT EXISTS(SELECT * + FROM information_schema.columns + WHERE table_name='note_settings' and column_name='sidebar_position') + THEN + ALTER TABLE "public"."note_settings" ADD COLUMN "sidebar_position" VARCHAR(255) NOT NULL DEFAULT 'content'; + END IF; +END $$; diff --git a/src/domain/entities/noteSettings.ts b/src/domain/entities/noteSettings.ts index 97efef96..c64c0a86 100644 --- a/src/domain/entities/noteSettings.ts +++ b/src/domain/entities/noteSettings.ts @@ -5,6 +5,11 @@ import type { Team } from './team.js'; */ export type InvitationHash = string; +/** + * Position of the note in the sidebar + */ +export type SidebarPosition = 'edge' | 'content' | 'none'; + /** * Notes settings entity */ @@ -39,6 +44,11 @@ export default interface NoteSettings { */ cover: string; + /** + * Position of the note in the sidebar + */ + sidebarPosition: SidebarPosition; + /** * Team members. Team is empty by default because note creator is not stored in team */ @@ -48,7 +58,7 @@ export default interface NoteSettings { /** * Attributes of public note settings */ -type NoteSettingsPublicProperties = 'customHostname' | 'isPublic' | 'invitationHash' | 'team' | 'cover' ; +type NoteSettingsPublicProperties = 'customHostname' | 'isPublic' | 'invitationHash' | 'team' | 'cover' | 'sidebarPosition' ; export interface NoteSettingsPublic extends Pick {} @@ -63,13 +73,14 @@ export function definePublicNoteSettings(noteSettings: NoteSettings): NoteSettin invitationHash: noteSettings.invitationHash, team: noteSettings.team, cover: noteSettings.cover, + sidebarPosition: noteSettings.sidebarPosition, }; } /** * Notes settings creation attributes, omitting id, because it's generated by database, omitting cover, because it should be generated after insertion of the note */ -type NoteSettingsCreationAttributes = Omit; +type NoteSettingsCreationAttributes = Omit; export type { NoteSettingsCreationAttributes diff --git a/src/presentation/http/router/noteSettings.test.ts b/src/presentation/http/router/noteSettings.test.ts index 3043e07e..9284ed0b 100644 --- a/src/presentation/http/router/noteSettings.test.ts +++ b/src/presentation/http/router/noteSettings.test.ts @@ -93,6 +93,7 @@ describe('NoteSettings API', () => { { isPublic: false, invitationHash: noteSettings.invitationHash, + sidebarPosition: 'content', team: [ { @@ -506,6 +507,7 @@ describe('NoteSettings API', () => { body: { isPublic: false, cover: 'new-image.png', + sidebarPosition: 'edge', }, url: `/note-settings/${note.publicId}`, }); @@ -515,6 +517,7 @@ describe('NoteSettings API', () => { if (expectedStatusCode === 200) { expect(response?.json().isPublic).toBe(false); expect(response?.json().cover).toBe('new-image.png'); + expect(response?.json().sidebarPosition).toBe('edge'); } }); diff --git a/src/presentation/http/router/noteSettings.ts b/src/presentation/http/router/noteSettings.ts index 00a1b17a..7b25abed 100644 --- a/src/presentation/http/router/noteSettings.ts +++ b/src/presentation/http/router/noteSettings.ts @@ -216,7 +216,7 @@ const NoteSettingsRouter: FastifyPluginCallback = (fa * Patch noteSettings by note id */ fastify.patch<{ - Body: Pick; + Body: Pick; Params: { notePublicId: NotePublicId; }; @@ -246,6 +246,9 @@ const NoteSettingsRouter: FastifyPluginCallback = (fa cover: { type: 'string', }, + sidebarPosition: { + $ref: 'NoteSettingsSchema#/properties/sidebarPosition', + }, }, }, response: { @@ -263,12 +266,13 @@ const NoteSettingsRouter: FastifyPluginCallback = (fa /** * @todo validate data */ - const { customHostname, isPublic, cover } = request.body; + const { customHostname, isPublic, cover, sidebarPosition } = request.body; const updatedNoteSettings = await noteSettingsService.patchNoteSettingsByNoteId(noteId, { customHostname, isPublic, cover, + sidebarPosition, }); if (updatedNoteSettings === null) { diff --git a/src/presentation/http/schema/NoteSettings.ts b/src/presentation/http/schema/NoteSettings.ts index d87c9a2b..b4f0e309 100644 --- a/src/presentation/http/schema/NoteSettings.ts +++ b/src/presentation/http/schema/NoteSettings.ts @@ -21,6 +21,11 @@ export const NoteSettingsSchema = { cover: { type: 'string', }, + sidebarPosition: { + type: 'string', + enum: ['edge', 'content', 'none'], + default: 'content', + }, team: { type: 'array', items: { diff --git a/src/repository/storage/postgres/orm/sequelize/noteSettings.ts b/src/repository/storage/postgres/orm/sequelize/noteSettings.ts index 50beb6bb..1378e2a8 100644 --- a/src/repository/storage/postgres/orm/sequelize/noteSettings.ts +++ b/src/repository/storage/postgres/orm/sequelize/noteSettings.ts @@ -38,6 +38,11 @@ export class NoteSettingsModel extends Model, * Id of the cover file */ public declare cover: CreationOptional; + + /** + * Position of the note in the sidebar + */ + public declare sidebarPosition: CreationOptional; } /** @@ -105,6 +110,11 @@ export default class NoteSettingsSequelizeStorage { type: DataTypes.STRING, allowNull: true, }, + sidebarPosition: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'content', + }, }, { tableName: this.tableName, sequelize: this.database, diff --git a/src/tests/utils/database-helpers.ts b/src/tests/utils/database-helpers.ts index 0af4d1a9..30a2b371 100644 --- a/src/tests/utils/database-helpers.ts +++ b/src/tests/utils/database-helpers.ts @@ -85,6 +85,7 @@ type NoteSettingsMockCreationAttributes = { isPublic: NoteSettings['isPublic']; invitationHash?: NoteSettings['invitationHash']; cover?: NoteSettings['cover']; + sidebarPosition?: NoteSettings['sidebarPosition']; }; /** @@ -232,10 +233,11 @@ export default class DatabaseHelpers { const customHostname = noteSettings.customHostname ?? null; const invitationHash = noteSettings.invitationHash ?? createInvitationHash(); const cover = noteSettings.cover ?? ''; + const sidebarPosition = noteSettings.sidebarPosition ?? 'content'; noteSettings.invitationHash = invitationHash; - await this.orm.connection.query(`INSERT INTO public.note_settings ("note_id", "custom_hostname", "is_public", "invitation_hash", "cover") VALUES (${noteSettings.noteId}, '${customHostname}', ${noteSettings.isPublic}, '${invitationHash}', '${cover}')`); + await this.orm.connection.query(`INSERT INTO public.note_settings ("note_id", "custom_hostname", "is_public", "invitation_hash", "cover", "sidebar_position") VALUES (${noteSettings.noteId}, '${customHostname}', ${noteSettings.isPublic}, '${invitationHash}', '${cover}', '${sidebarPosition}')`); return noteSettings; } From 6a6fa89742669d960a5bbba050d2f60830a80b38 Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Wed, 23 Sep 2026 19:03:37 +0300 Subject: [PATCH 2/2] feat: expose sidebarPosition in note hierarchy --- src/domain/entities/NoteHierarchy.ts | 7 ++- src/domain/entities/note.ts | 6 ++ src/domain/service/note.ts | 7 ++- src/presentation/http/router/note.test.ts | 62 +++++++++++++++++++ .../http/router/noteSettings.test.ts | 1 + src/presentation/http/schema/NoteHierarchy.ts | 4 ++ .../storage/postgres/orm/sequelize/note.ts | 8 ++- 7 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/domain/entities/NoteHierarchy.ts b/src/domain/entities/NoteHierarchy.ts index c6ff0765..0fb638c6 100644 --- a/src/domain/entities/NoteHierarchy.ts +++ b/src/domain/entities/NoteHierarchy.ts @@ -1,4 +1,5 @@ import type { NotePublicId } from './note.js'; +import type { SidebarPosition } from './noteSettings.js'; /** * Note Tree entity @@ -15,9 +16,13 @@ export interface NoteHierarchy { */ noteTitle: string; + /** + * Position of the root note in the sidebar + */ + sidebarPosition?: SidebarPosition; + /** * child notes */ childNotes: NoteHierarchy[] | null; - } diff --git a/src/domain/entities/note.ts b/src/domain/entities/note.ts index 82f375c4..ca2fd96e 100644 --- a/src/domain/entities/note.ts +++ b/src/domain/entities/note.ts @@ -1,5 +1,6 @@ import type User from '@domain/entities/user.js'; import type EditorTool from './editorTools.js'; +import type { SidebarPosition } from './noteSettings.js'; /** * Note internal id. Used to query Note by internal API @@ -111,4 +112,9 @@ export type NotePreview = { * Parent note id */ parentId: NoteInternalId | null; + + /** + * Position of the note in the sidebar + */ + sidebarPosition: SidebarPosition; }; diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 6e56591f..04f64f0a 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -515,7 +515,8 @@ export default class NoteService { } /** - * Reutrn a tree structure of notes with childNotes for the given note id + * Reutrn a tree structure of notes with childNotes for the given note id. + * The sidebar position is returned on the root node only. * @param noteId - id of the note to get structure * @returns - Object of notes. */ @@ -534,6 +535,7 @@ export default class NoteService { if (!notesRows || notesRows.length === 0) { return null; } + // Step 1: Parse and initialize all notes notesRows.forEach((note) => { notesMap.set(note.noteId, { @@ -547,6 +549,9 @@ export default class NoteService { notesRows.forEach((note) => { if (note.parentId === null) { root = notesMap.get(note.noteId) ?? null; + if (root !== null) { + root.sidebarPosition = note.sidebarPosition; + } } else { const parent = notesMap.get(note.parentId); diff --git a/src/presentation/http/router/note.test.ts b/src/presentation/http/router/note.test.ts index 575932ac..faecb756 100644 --- a/src/presentation/http/router/note.test.ts +++ b/src/presentation/http/router/note.test.ts @@ -2298,6 +2298,7 @@ describe('Note API', () => { await global.db.insertNoteSetting({ noteId: note.id, isPublic: true, + sidebarPosition: 'edge', }); return { @@ -2309,6 +2310,7 @@ describe('Note API', () => { expected: (note: Note, childNote: Note | null) => ({ noteId: note.publicId, noteTitle: 'text', + sidebarPosition: 'edge', childNotes: childNote, }), }, @@ -2329,10 +2331,12 @@ describe('Note API', () => { await global.db.insertNoteSetting({ noteId: childNote.id, isPublic: true, + sidebarPosition: 'edge', }); await global.db.insertNoteSetting({ noteId: parentNote.id, isPublic: true, + sidebarPosition: 'none', }); await global.db.insertNoteRelation({ noteId: childNote.id, @@ -2347,6 +2351,7 @@ describe('Note API', () => { expected: (note: Note, childNote: Note | null) => ({ noteId: note.publicId, noteTitle: 'text', + sidebarPosition: 'none', childNotes: [ { noteId: childNote?.publicId, @@ -2374,5 +2379,62 @@ describe('Note API', () => { expected(note, childNote) ); }); + + test('Uses parent sidebar position while linked and own position after unlinking', async () => { + const rootNote = await global.db.insertNote({ + creatorId: user.id, + content: DEFAULT_NOTE_CONTENT, + }); + const childNote = await global.db.insertNote({ + creatorId: user.id, + content: DEFAULT_NOTE_CONTENT, + }); + + await global.db.insertNoteSetting({ + noteId: rootNote.id, + isPublic: true, + sidebarPosition: 'none', + }); + await global.db.insertNoteSetting({ + noteId: childNote.id, + isPublic: true, + sidebarPosition: 'edge', + }); + await global.db.insertNoteRelation({ + noteId: childNote.id, + parentId: rootNote.id, + }); + + let response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/note-hierarchy/${childNote.publicId}`, + }); + + expect(response?.json().noteHierarchy.sidebarPosition).toBe('none'); + + const detachResponse = await global.api?.fakeRequest({ + method: 'DELETE', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${childNote.publicId}/relation`, + }); + + expect(detachResponse?.statusCode).toBe(200); + expect(detachResponse?.json().isDeleted).toBe(true); + + response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/note-hierarchy/${childNote.publicId}`, + }); + + expect(response?.json().noteHierarchy.sidebarPosition).toBe('edge'); + }); }); }); diff --git a/src/presentation/http/router/noteSettings.test.ts b/src/presentation/http/router/noteSettings.test.ts index 9284ed0b..66d6560a 100644 --- a/src/presentation/http/router/noteSettings.test.ts +++ b/src/presentation/http/router/noteSettings.test.ts @@ -261,6 +261,7 @@ describe('NoteSettings API', () => { { isPublic: false, invitationHash: noteSettings.invitationHash, + sidebarPosition: 'content', team: [ { diff --git a/src/presentation/http/schema/NoteHierarchy.ts b/src/presentation/http/schema/NoteHierarchy.ts index 0e169f60..c89fee87 100644 --- a/src/presentation/http/schema/NoteHierarchy.ts +++ b/src/presentation/http/schema/NoteHierarchy.ts @@ -11,6 +11,10 @@ export const NoteHierarchySchema = { type: 'string', maxLength: 50, }, + sidebarPosition: { + type: 'string', + enum: ['edge', 'content', 'none'], + }, childNotes: { type: 'array', items: { $ref: 'NoteHierarchySchema#' }, diff --git a/src/repository/storage/postgres/orm/sequelize/note.ts b/src/repository/storage/postgres/orm/sequelize/note.ts index 48138345..0bcfa9b5 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -409,9 +409,11 @@ export default class NoteSequelizeStorage { n.id AS "noteId", n.content, n.public_id AS "publicId", - nr.parent_id AS "parentId" + nr.parent_id AS "parentId", + ns.sidebar_position AS "sidebarPosition" 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 + LEFT JOIN ${String(this.database.literal('note_settings').val)} ns ON n.id = ns.note_id WHERE n.id = :startNoteId UNION ALL @@ -420,10 +422,12 @@ export default class NoteSequelizeStorage { n.id AS "noteId", n.content, n.public_id AS "publicId", - nr.parent_id AS "parentId" + nr.parent_id AS "parentId", + ns.sidebar_position AS "sidebarPosition" 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" + LEFT JOIN ${String(this.database.literal('note_settings').val)} ns ON n.id = ns.note_id ) SELECT * FROM note_tree; `;