Skip to content

Commit 71fb7fb

Browse files
Merge pull request #276 from codex-team/feat/return-note
feat(parent structure): add method that return note parent structure
2 parents 98c01e7 + 31aeb92 commit 71fb7fb

9 files changed

Lines changed: 256 additions & 8 deletions

File tree

docker-compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
version: "3.2"
1+
version: '3.2'
2+
23
services:
34
api:
45
build:

src/domain/service/note.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,4 +441,16 @@ export default class NoteService {
441441

442442
return noteHistoryPublic;
443443
}
444+
445+
/**
446+
* Return a sequence of parent notes for the given note id.
447+
* @param noteId - id of the note to get parent structure
448+
* @returns - array of notes that are parent structure of the note
449+
*/
450+
public async getNoteParents(noteId: NoteInternalId): Promise<Note[]> {
451+
const noteIds: NoteInternalId[] = await this.noteRelationsRepository.getNoteParentsIds(noteId);
452+
const noteParents = await this.noteRepository.getNotesByIds(noteIds);
453+
454+
return noteParents;
455+
}
444456
}

src/presentation/http/router/note.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,152 @@ describe('Note API', () => {
518518

519519
expect(response?.json().message).toStrictEqual(expectedMessage);
520520
});
521+
522+
test('Returns one parents note in case when note has one parent', async () => {
523+
/** Create test user */
524+
const user = await global.db.insertUser();
525+
526+
/** Create access token for the user */
527+
const accessToken = global.auth(user.id);
528+
529+
/** Create test note - a parent note */
530+
const parentNote = await global.db.insertNote({
531+
creatorId: user.id,
532+
});
533+
534+
/** Create test note - a child note */
535+
const childNote = await global.db.insertNote({
536+
creatorId: user.id,
537+
});
538+
539+
/** Create test note settings */
540+
await global.db.insertNoteSetting({
541+
noteId: childNote.id,
542+
isPublic: true,
543+
});
544+
545+
/** Create test note relation */
546+
await global.db.insertNoteRelation({
547+
parentId: parentNote.id,
548+
noteId: childNote.id,
549+
});
550+
551+
const response = await global.api?.fakeRequest({
552+
method: 'GET',
553+
headers: {
554+
authorization: `Bearer ${accessToken}`,
555+
},
556+
url: `/note/${childNote.publicId}`,
557+
});
558+
559+
expect(response?.statusCode).toBe(200);
560+
561+
expect(response?.json()).toMatchObject({
562+
parents: [
563+
{
564+
id: parentNote.publicId,
565+
content: parentNote.content,
566+
},
567+
],
568+
});
569+
});
570+
571+
test('Returns note parents in correct order in case when parents created in a non-linear order', async () => {
572+
/** Create test user */
573+
const user = await global.db.insertUser();
574+
575+
/** Create access token for the user */
576+
const accessToken = global.auth(user.id);
577+
578+
/** Create test note - a grand parent note */
579+
const firstNote = await global.db.insertNote({
580+
creatorId: user.id,
581+
});
582+
583+
/** Create test note - a parent note */
584+
const secondNote = await global.db.insertNote({
585+
creatorId: user.id,
586+
});
587+
588+
/** Create test note - a child note */
589+
const thirdNote = await global.db.insertNote({
590+
creatorId: user.id,
591+
});
592+
593+
/** Create test note settings */
594+
await global.db.insertNoteSetting({
595+
noteId: secondNote.id,
596+
isPublic: true,
597+
});
598+
599+
/** Create note relation between parent and grandParentNote */
600+
await global.db.insertNoteRelation({
601+
parentId: firstNote.id,
602+
noteId: thirdNote.id,
603+
});
604+
605+
/** Create test note relation */
606+
await global.db.insertNoteRelation({
607+
parentId: thirdNote.id,
608+
noteId: secondNote.id,
609+
});
610+
611+
const response = await global.api?.fakeRequest({
612+
method: 'GET',
613+
headers: {
614+
authorization: `Bearer ${accessToken}`,
615+
},
616+
url: `/note/${secondNote.publicId}`,
617+
});
618+
619+
expect(response?.statusCode).toBe(200);
620+
621+
expect(response?.json()).toMatchObject({
622+
parents: [
623+
{
624+
id: firstNote.publicId,
625+
content: firstNote.content,
626+
},
627+
{
628+
id: thirdNote.publicId,
629+
content: thirdNote.content,
630+
},
631+
],
632+
});
633+
});
634+
635+
test('Returns empty array in case where there is no relation exist for the note', async () => {
636+
/** Create test user */
637+
const user = await global.db.insertUser();
638+
639+
/** Create access token for the user */
640+
const accessToken = global.auth(user.id);
641+
642+
/** Create test note - a child note */
643+
const note = await global.db.insertNote({
644+
creatorId: user.id,
645+
});
646+
647+
/** Create test note settings */
648+
await global.db.insertNoteSetting({
649+
noteId: note.id,
650+
isPublic: true,
651+
});
652+
653+
const response = await global.api?.fakeRequest({
654+
method: 'GET',
655+
headers: {
656+
authorization: `Bearer ${accessToken}`,
657+
},
658+
url: `/note/${note.publicId}`,
659+
});
660+
661+
expect(response?.statusCode).toBe(200);
662+
663+
expect(response?.json()).toMatchObject({
664+
parents: [],
665+
});
666+
});
521667
});
522668

523669
describe('PATCH note/:notePublicId ', () => {

src/presentation/http/router/note.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
8585
canEdit: boolean;
8686
};
8787
tools: EditorTool[];
88+
parents: NotePublic[];
8889
} | ErrorResponse;
8990
}>('/:notePublicId', {
9091
config: {
@@ -123,6 +124,12 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
123124
$ref: 'EditorToolSchema',
124125
},
125126
},
127+
parents: {
128+
type: 'array',
129+
items: {
130+
$ref: 'NoteSchema',
131+
},
132+
},
126133
},
127134
},
128135
},
@@ -172,11 +179,18 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
172179
*/
173180
const canEdit = memberRole === MemberRole.Write;
174181

182+
const noteParentStructure = await noteService.getNoteParents(noteId);
183+
184+
const noteParentsPublic = noteParentStructure.map((notes) => {
185+
return definePublicNote(notes);
186+
});
187+
175188
return reply.send({
176189
note: notePublic,
177190
parentNote: parentNote,
178191
accessRights: { canEdit: canEdit },
179192
tools: noteTools,
193+
parents: noteParentsPublic,
180194
});
181195
});
182196

src/repository/note.repository.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,13 @@ export default class NoteRepository {
8181
public async getNoteListByUserId(id: number, offset: number, limit: number): Promise<Note[]> {
8282
return await this.storage.getNoteListByUserId(id, offset, limit);
8383
}
84+
85+
/**
86+
* Get all notes based on their ids
87+
* @param noteIds : list of note ids
88+
* @returns an array of notes
89+
*/
90+
public async getNotesByIds(noteIds: NoteInternalId[]): Promise<Note[]> {
91+
return await this.storage.getNotesByIds(noteIds);
92+
}
8493
}

src/repository/noteRelations.repository.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,13 @@ export default class NoteRelationsRepository {
6767
public async hasRelation(noteId: NoteInternalId): Promise<boolean> {
6868
return await this.storage.hasRelation(noteId);
6969
}
70+
71+
/**
72+
* Get all note parents based on note id
73+
* @param noteId : note id to get all its parents
74+
* @returns an array of note parents ids
75+
*/
76+
public async getNoteParentsIds(noteId: NoteInternalId): Promise<NoteInternalId[]> {
77+
return await this.storage.getNoteParentsIds(noteId);
78+
}
7079
}

src/repository/storage/postgres/orm/sequelize/note.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { CreationOptional, InferAttributes, InferCreationAttributes, ModelStatic, NonAttribute, Sequelize } from 'sequelize';
2-
import { DataTypes, Model } from 'sequelize';
2+
import { DataTypes, Model, Op } from 'sequelize';
33
import type Orm from '@repository/storage/postgres/orm/sequelize/index.js';
44
import type { Note, NoteCreationAttributes, NoteInternalId, NotePublicId } from '@domain/entities/note.js';
55
import { UserModel } from '@repository/storage/postgres/orm/sequelize/user.js';
66
import type { NoteSettingsModel } from './noteSettings.js';
77
import type { NoteVisitsModel } from './noteVisits.js';
8-
import { DomainError } from '@domain/entities/DomainError.js';
98
import type { NoteHistoryModel } from './noteHistory.js';
109

1110
/* eslint-disable @typescript-eslint/naming-convention */
@@ -233,11 +232,11 @@ export default class NoteSequelizeStorage {
233232
*/
234233
public async getNoteListByUserId(userId: number, offset: number, limit: number): Promise<Note[]> {
235234
if (this.visitsModel === null) {
236-
throw new DomainError('NoteVisit model should be defined');
235+
throw new Error('NoteStorage: NoteVisit model should be defined');
237236
}
238237

239238
if (!this.settingsModel) {
240-
throw new Error('Note settings model not initialized');
239+
throw new Error('NoteStorage: Note settings model not initialized');
241240
}
242241

243242
const reply = await this.model.findAll({
@@ -293,7 +292,7 @@ export default class NoteSequelizeStorage {
293292
*/
294293
public async getNoteByHostname(hostname: string): Promise<Note | null> {
295294
if (!this.settingsModel) {
296-
throw new Error('Note settings model not initialized');
295+
throw new Error('NoteStorage: Note settings model not initialized');
297296
}
298297

299298
/**
@@ -324,4 +323,27 @@ export default class NoteSequelizeStorage {
324323
},
325324
});
326325
};
326+
327+
/**
328+
* Get all notes based on their ids in the same order of passed ids
329+
* @param noteIds - list of note ids
330+
*/
331+
public async getNotesByIds(noteIds: NoteInternalId[]): Promise<Note[]> {
332+
if (noteIds.length === 0) {
333+
return [];
334+
}
335+
336+
const notes: Note[] = await this.model.findAll({
337+
where: {
338+
id: {
339+
[Op.in]: noteIds,
340+
},
341+
},
342+
order: [
343+
this.database.literal(`ARRAY_POSITION(ARRAY[${noteIds.map(id => `${id}`).join(',')}], id)`),
344+
],
345+
});
346+
347+
return notes;
348+
}
327349
}

src/repository/storage/postgres/orm/sequelize/noteRelations.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CreationOptional, InferAttributes, InferCreationAttributes, ModelStatic, Sequelize } from 'sequelize';
2+
import { QueryTypes } from 'sequelize';
23
import { Op } from 'sequelize';
34
import { NoteModel } from '@repository/storage/postgres/orm/sequelize/note.js';
45
import type Orm from '@repository/storage/postgres/orm/sequelize/index.js';
@@ -209,4 +210,39 @@ export default class NoteRelationsSequelizeStorage {
209210

210211
return foundNote !== null;
211212
};
213+
214+
/**
215+
* Get all parent notes of a note that a user has access to,
216+
* where the user has access to.
217+
* @param noteId - the ID of the note.
218+
*/
219+
public async getNoteParentsIds(noteId: NoteInternalId): Promise<NoteInternalId[]> {
220+
// Query to get all parent notes of a note.
221+
// The query uses a recursive common table expression (CTE) to get all parent notes of a note.
222+
// It starts from the note with the ID :startNoteId and recursively gets all parent notes.
223+
// It returns a list of note ID and parent ID of the note.
224+
const query = `
225+
WITH RECURSIVE note_parents AS (
226+
SELECT np.note_id, np.parent_id
227+
FROM ${String(this.database.literal(this.tableName).val)} np
228+
WHERE np.note_id = :startNoteId
229+
UNION ALL
230+
SELECT nr.note_id, nr.parent_id
231+
FROM ${String(this.database.literal(this.tableName).val)} nr
232+
INNER JOIN note_parents np ON np.parent_id = nr.note_id
233+
)
234+
SELECT np.note_id AS "noteId", np.parent_id AS "parentId"
235+
FROM note_parents np;`;
236+
237+
const result = await this.model.sequelize?.query(query, {
238+
replacements: { startNoteId: noteId },
239+
type: QueryTypes.SELECT,
240+
});
241+
242+
let noteParents = (result as { noteId: number; parentId: number }[])?.map(note => note.parentId) ?? [];
243+
244+
noteParents.reverse();
245+
246+
return noteParents;
247+
}
212248
}

src/repository/storage/postgres/orm/sequelize/teams.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { UserModel } from './user.js';
77
import { MemberRole } from '@domain/entities/team.js';
88
import type User from '@domain/entities/user.js';
99
import type { NoteInternalId } from '@domain/entities/note.js';
10-
import { DomainError } from '@domain/entities/DomainError.js';
1110

1211
/**
1312
* Class representing a teams model in database
@@ -188,7 +187,7 @@ export default class TeamsSequelizeStorage {
188187
*/
189188
public async getTeamMembersWithUserInfoByNoteId(noteId: NoteInternalId): Promise<Team> {
190189
if (!this.userModel) {
191-
throw new DomainError('User model not initialized');
190+
throw new Error('TeamStorage: User model not defined');
192191
}
193192

194193
return await this.model.findAll({

0 commit comments

Comments
 (0)