From 2ad6cd645c2bbe6d1b810afcc160031f39da0d4c Mon Sep 17 00:00:00 2001 From: Tushar Ahire Date: Fri, 1 Nov 2024 15:42:45 +0530 Subject: [PATCH 1/2] adds endpoint and functionality to list notes related parent note --- src/domain/service/note.ts | 13 +++ src/presentation/http/http-api.ts | 1 + .../http/middlewares/note/useNoteResolver.ts | 8 +- src/presentation/http/router/noteList.ts | 94 +++++++++++++++++++ src/repository/index.ts | 1 + src/repository/note.repository.ts | 10 ++ .../storage/postgres/orm/sequelize/note.ts | 64 +++++++++++++ 7 files changed, 189 insertions(+), 2 deletions(-) diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 95976baf..cb92e483 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -236,6 +236,19 @@ export default class NoteService { items: await this.noteRepository.getNoteListByUserId(userId, offset, this.noteListPortionSize), }; } + + /** + * Returns note list by parent note id + * @param parentId - id of the parent note + * @param page - number of current page + * @returns list of the notes ordered by time of last visit + */ + public async getNoteListByParentNote(parentNoteId: NoteInternalId, page: number): Promise { + const offset = (page - 1) * this.noteListPortionSize; + return { + items: await this.noteRepository.getNoteListByParentNote(parentNoteId, offset, this.noteListPortionSize), + }; + } /** * Create note relation diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index 31c1046a..6376db8c 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -207,6 +207,7 @@ export default class HttpApi implements Api { await this.server?.register(NoteListRouter, { prefix: '/notes', noteService: domainServices.noteService, + noteSettingsService: domainServices.noteSettingsService }); await this.server?.register(JoinRouter, { diff --git a/src/presentation/http/middlewares/note/useNoteResolver.ts b/src/presentation/http/middlewares/note/useNoteResolver.ts index 73ae1da3..6ae3e200 100644 --- a/src/presentation/http/middlewares/note/useNoteResolver.ts +++ b/src/presentation/http/middlewares/note/useNoteResolver.ts @@ -4,7 +4,7 @@ import { notEmpty } from '@infrastructure/utils/empty.js'; import { StatusCodes } from 'http-status-codes'; import hasProperty from '@infrastructure/utils/hasProperty.js'; import { getLogger } from '@infrastructure/logging/index.js'; -import type { Note, NotePublicId } from '@domain/entities/note.js'; +import type { Note, NotePublicId, NoteInternalId } from '@domain/entities/note.js'; /** * Add middleware for resolve Note by public id and add it to request @@ -36,8 +36,12 @@ export default function useNoteResolver(noteService: NoteService): { return await noteService.getNoteByPublicId(publicId); } + else if (hasProperty(requestData, 'parentNoteId') && notEmpty(requestData.parentNoteId)) { + const noteId = requestData.parentNoteId as NoteInternalId; + return await noteService.getNoteById(noteId) + } } - + return { noteResolver: async function noteIdResolver(request, reply) { let note: Note | undefined; diff --git a/src/presentation/http/router/noteList.ts b/src/presentation/http/router/noteList.ts index 49507d2f..b12a5b13 100644 --- a/src/presentation/http/router/noteList.ts +++ b/src/presentation/http/router/noteList.ts @@ -1,7 +1,12 @@ import type { FastifyPluginCallback } from 'fastify'; import type NoteService from '@domain/service/note.js'; +import useNoteResolver from '../middlewares/note/useNoteResolver.js'; +import useNoteSettingsResolver from '../middlewares/noteSettings/useNoteSettingsResolver.js'; +import useMemberRoleResolver from '../middlewares/noteSettings/useMemberRoleResolver.js'; +import type NoteSettingsService from '@domain/service/noteSettings.js'; import { definePublicNote, type NotePublic } from '@domain/entities/notePublic.js'; import type { NoteListPublic } from '@domain/entities/noteList.js'; +import { NoteInternalId } from '@domain/entities/note.js'; /** * Interface for the noteList router. @@ -12,6 +17,12 @@ interface NoteListRouterOptions { */ noteService: NoteService; + /** + * Note Settings service instance + */ + noteSettingsService: NoteSettingsService; + + } /** @@ -22,6 +33,25 @@ interface NoteListRouterOptions { */ const NoteListRouter: FastifyPluginCallback = (fastify, opts, done) => { const noteService = opts.noteService; + const noteSettingsService = opts.noteSettingsService; + + /** + * Prepare note id resolver middleware + * It should be used in routes that accepts note public id + */ + const { noteResolver } = useNoteResolver(noteService); + + /** + * Prepare note settings resolver middleware + * It should be used to use note settings in middlewares + */ + const { noteSettingsResolver } = useNoteSettingsResolver(noteSettingsService); + + /** + * Prepare user role resolver middleware + * It should be used to use user role in middlewares + */ + const { memberRoleResolver } = useMemberRoleResolver(noteSettingsService); /** * Get note list ordered by time of last visit @@ -77,6 +107,70 @@ const NoteListRouter: FastifyPluginCallback = (fastify, o return reply.send(noteListPublic); }); + /** + * Get note list by parent note + */ + fastify.get<{ + Params: { + parentNoteId: NoteInternalId; + } + Querystring: { + page: number; + }; + }>('/:parentNoteId', { + config: { + policy: [ + 'notePublicOrUserInTeam', + ], + }, + schema: { + params: { + notePublicId: { + $ref: 'NoteSchema#/properties/id', + }, + }, querystring: { + page: { + type: 'number', + minimum: 1, + maximum: 30, + }, + }, response: { + '2xx': { + description: 'Query notelist', + properties: { + items: { + id: { type: 'string' }, + content: { type: 'string' }, + createdAt: { type: 'string' }, + creatorId: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }, + }, + }, + }, preHandler: [ + noteResolver, + noteSettingsResolver, + memberRoleResolver, + ], + }, async (request, reply) => { + const { parentNoteId } = await request.params; + const { page } = await request.query; + + const noteList = await noteService.getNoteListByParentNote(parentNoteId, page); + /** + * Wrapping Notelist for public use + */ + const noteListItemsPublic: NotePublic[] = noteList.items.map(definePublicNote); + + const noteListPublic: NoteListPublic = { + items: noteListItemsPublic, + }; + + return reply.send(noteListPublic); + }) + + done(); }; diff --git a/src/repository/index.ts b/src/repository/index.ts index ed40d01e..35ff6811 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -139,6 +139,7 @@ export async function init(orm: Orm, s3Config: S3StorageConfig): Promise { return await this.storage.getNotesByIds(noteIds); } + + /** + * Gets note list by parent note id + * @param parentId - parent note id + * @param offset - number of skipped notes + * @param limit - number of notes to get + */ + public async getNoteListByParentNote(parentNoteId: number, offset: number, limit: number): Promise { + return await this.storage.getNoteListByParentNote(parentNoteId, offset, limit); + } } diff --git a/src/repository/storage/postgres/orm/sequelize/note.ts b/src/repository/storage/postgres/orm/sequelize/note.ts index c45b3be5..c620c39a 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -5,6 +5,8 @@ import type { Note, NoteCreationAttributes, NoteInternalId, NotePublicId } from import { UserModel } from '@repository/storage/postgres/orm/sequelize/user.js'; import type { NoteSettingsModel } from './noteSettings.js'; import type { NoteVisitsModel } from './noteVisits.js'; +import type { NoteRelationsModel } from './noteRelations.js'; +import { DomainError } from '@domain/entities/DomainError.js'; import type { NoteHistoryModel } from './noteHistory.js'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -75,6 +77,7 @@ export default class NoteSequelizeStorage { public historyModel: typeof NoteHistoryModel | null = null; + public relationsModel: typeof NoteRelationsModel | null = null; /** * Database instance */ @@ -155,6 +158,14 @@ export default class NoteSequelizeStorage { }); }; + public createAssociationWithNoteRelationsModel(model: ModelStatic): void { + this.relationsModel = model; + + this.model.hasMany(this.relationsModel, { + foreignKey: 'parentId', + as: 'noteRelations', + }) + } /** * Insert note to database * @param options - note creation options @@ -346,4 +357,57 @@ export default class NoteSequelizeStorage { return notes; } + + /** + * Gets note list by parent note id + * @param parentId - parent note id + * @param offset - number of skipped notes + * @param limit - number of notes to get + */ + public async getNoteListByParentNote(parentId: NoteInternalId, offset: number, limit: number): Promise { + if (!this.relationsModel) { + throw new Error('NoteRelations model not initialized'); + } + + if (!this.settingsModel) { + throw new Error('Note settings model not initialized'); + } + + const childNotes = await this.relationsModel.findAll({ + where: { parentId }, + attributes: ['noteId'], + }); + + const noteIds = childNotes.map(relation => relation.noteId); + + + const reply = await this.model.findAll({ + where: { + id: { + [Op.in]: noteIds, + }, + }, + include:[{ + model: this.settingsModel, + as: 'noteSettings', + attributes: ['cover'], + duplicating: false, + }], + offset, + limit, + }); + + return reply.map((note) => { + return { + id: note.id, + cover: note.noteSettings!.cover, + content: note.content, + updatedAt: note.updatedAt, + createdAt: note.createdAt, + publicId: note.publicId, + creatorId: note.creatorId, + tools: note.tools, + }; + }); + } } From 617ebc85e38532828dd4195a333e94c965082958 Mon Sep 17 00:00:00 2001 From: Tushar Ahire Date: Tue, 5 Nov 2024 14:29:21 +0530 Subject: [PATCH 2/2] [Feature] adds tests for note list by parent note api endpoint --- src/presentation/http/router/noteList.test.ts | 129 ++++++++++++++++++ src/presentation/http/router/noteList.ts | 1 + 2 files changed, 130 insertions(+) diff --git a/src/presentation/http/router/noteList.test.ts b/src/presentation/http/router/noteList.test.ts index 83de4587..66b1cf16 100644 --- a/src/presentation/http/router/noteList.test.ts +++ b/src/presentation/http/router/noteList.test.ts @@ -144,3 +144,132 @@ describe('GET /notes?page', () => { } }); }); + + +describe('GET /notes/:parentNoteId?page', () => { + test.each([ + /** + * Returns noteList with specified length + * User is authorized + */ + { + isAuthorized: true, + expectedStatusCode: 200, + expectedMessage: null, + expectedLength: 30, + pageNumber: 1, + }, + /** + * Returns noteList with specified length (for last page) + * User is authorized + */ + { + isAuthorized: true, + expectedStatusCode: 200, + expectedMessage: null, + expectedLength: 19, + pageNumber: 2, + }, + /** + * Returns noteList with no items if there are no notes for certain parentNote + * User is authorized + */ + { + isAuthorized: true, + expectedStatusCode: 200, + expectedMessage: null, + expectedLength: 0, + pageNumber: 3, + }, + /** + * Returns 'querystring/page must be >= 1' message when page < 0 + */ + { + isAuthorized: true, + expectedStatusCode: 400, + expectedMessage: 'querystring/page must be >= 1', + expectedLength: 0, + pageNumber: -1, + }, + /** + * Returns 'querystring/page must be <= 30' message when page is too large (maximum page numbrer is 30 by default) + */ + { + isAuthorized: true, + expectedStatusCode: 400, + expectedMessage: 'querystring/page must be <= 30', + expectedLength: 0, + pageNumber: 31, + }, + /** + * Returns 'unauthorized' message when user is not authorized + */ + { + isAuthorized: false, + expectedStatusCode: 401, + expectedMessage: 'You must be authenticated to access this resource', + expectedLength: 0, + pageNumber: 1, + }, + ])('Get note list', async ({ isAuthorized, expectedStatusCode, expectedMessage, expectedLength, pageNumber }) => { + const portionSize = 49; + let accessToken; + + /** Insert creator and randomGuy */ + const creator = await global.db.insertUser(); + + const randomGuy = await global.db.insertUser(); + + if (isAuthorized) { + accessToken = global.auth(randomGuy.id); + } + + const parentNote = await global.db.insertNote({ + creatorId: creator.id, + }); + + await global.db.insertNoteSetting({ + noteId: parentNote.id, + cover: 'DZnvqi63.png', + isPublic: true, + }); + + for (let i = 0; i < portionSize; i++) { + const note = await global.db.insertNote({ + creatorId: creator.id, + }); + + await global.db.insertNoteSetting({ + noteId: note.id, + cover: 'DZnvqi63.png', + isPublic: true, + }); + + await global.db.insertNoteRelation({ + parentId: parentNote.id, + noteId: note.id, + }); + } + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/notes/${parentNote.id}?page=${pageNumber}`, + }); + + const body = response?.json(); + + if (expectedMessage !== null) { + expect(response?.statusCode).toBe(expectedStatusCode); + + expect(body.message).toBe(expectedMessage); + } else { + expect(response?.statusCode).toBe(expectedStatusCode); + + expect(body.items).toHaveLength(expectedLength); + } + }); +}); + diff --git a/src/presentation/http/router/noteList.ts b/src/presentation/http/router/noteList.ts index b12a5b13..c4d5c847 100644 --- a/src/presentation/http/router/noteList.ts +++ b/src/presentation/http/router/noteList.ts @@ -120,6 +120,7 @@ const NoteListRouter: FastifyPluginCallback = (fastify, o }>('/:parentNoteId', { config: { policy: [ + 'authRequired', 'notePublicOrUserInTeam', ], },