Skip to content

Commit

Permalink
Merge pull request #276 from codex-team/feat/return-note
Browse files Browse the repository at this point in the history
feat(parent structure): add method that return note parent structure
  • Loading branch information
dependentmadani authored Oct 26, 2024
2 parents 98c01e7 + 31aeb92 commit 71fb7fb
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 8 deletions.
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
version: "3.2"
version: '3.2'

services:
api:
build:
Expand Down
12 changes: 12 additions & 0 deletions src/domain/service/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Note[]> {
const noteIds: NoteInternalId[] = await this.noteRelationsRepository.getNoteParentsIds(noteId);
const noteParents = await this.noteRepository.getNotesByIds(noteIds);

return noteParents;
}
}
146 changes: 146 additions & 0 deletions src/presentation/http/router/note.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ', () => {
Expand Down
14 changes: 14 additions & 0 deletions src/presentation/http/router/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
canEdit: boolean;
};
tools: EditorTool[];
parents: NotePublic[];
} | ErrorResponse;
}>('/:notePublicId', {
config: {
Expand Down Expand Up @@ -123,6 +124,12 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
$ref: 'EditorToolSchema',
},
},
parents: {
type: 'array',
items: {
$ref: 'NoteSchema',
},
},
},
},
},
Expand Down Expand Up @@ -172,11 +179,18 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (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,
});
});

Expand Down
9 changes: 9 additions & 0 deletions src/repository/note.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,13 @@ export default class NoteRepository {
public async getNoteListByUserId(id: number, offset: number, limit: number): Promise<Note[]> {
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<Note[]> {
return await this.storage.getNotesByIds(noteIds);
}
}
9 changes: 9 additions & 0 deletions src/repository/noteRelations.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,13 @@ export default class NoteRelationsRepository {
public async hasRelation(noteId: NoteInternalId): Promise<boolean> {
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<NoteInternalId[]> {
return await this.storage.getNoteParentsIds(noteId);
}
}
32 changes: 27 additions & 5 deletions src/repository/storage/postgres/orm/sequelize/note.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -233,11 +232,11 @@ export default class NoteSequelizeStorage {
*/
public async getNoteListByUserId(userId: number, offset: number, limit: number): Promise<Note[]> {
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({
Expand Down Expand Up @@ -293,7 +292,7 @@ export default class NoteSequelizeStorage {
*/
public async getNoteByHostname(hostname: string): Promise<Note | null> {
if (!this.settingsModel) {
throw new Error('Note settings model not initialized');
throw new Error('NoteStorage: Note settings model not initialized');
}

/**
Expand Down Expand Up @@ -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<Note[]> {
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;
}
}
36 changes: 36 additions & 0 deletions src/repository/storage/postgres/orm/sequelize/noteRelations.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<NoteInternalId[]> {
// 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;
}
}
3 changes: 1 addition & 2 deletions src/repository/storage/postgres/orm/sequelize/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -188,7 +187,7 @@ export default class TeamsSequelizeStorage {
*/
public async getTeamMembersWithUserInfoByNoteId(noteId: NoteInternalId): Promise<Team> {
if (!this.userModel) {
throw new DomainError('User model not initialized');
throw new Error('TeamStorage: User model not defined');
}

return await this.model.findAll({
Expand Down

0 comments on commit 71fb7fb

Please sign in to comment.