diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 95976baf..602430e5 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -237,6 +237,20 @@ export default class NoteService { }; } + /** + * Returns note list by parent note id + * @param parentNoteId - 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 * @param noteId - id of the current note diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index 31c1046a..e607e4d8 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/router/noteList.test.ts b/src/presentation/http/router/noteList.test.ts index 83de4587..6e997ed5 100644 --- a/src/presentation/http/router/noteList.test.ts +++ b/src/presentation/http/router/noteList.test.ts @@ -144,3 +144,179 @@ 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, + isTeamMember: false, + isPublic: true, + }, + /** + * Returns noteList with specified length (for last page) + * User is authorized + */ + { + isAuthorized: true, + expectedStatusCode: 200, + expectedMessage: null, + expectedLength: 19, + pageNumber: 2, + isTeamMember: false, + isPublic: true, + }, + /** + * 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, + isTeamMember: false, + isPublic: true, + }, + /** + * Returns 'querystring/page must be >= 1' message when page < 0 + */ + { + isAuthorized: true, + expectedStatusCode: 400, + expectedMessage: 'querystring/page must be >= 1', + expectedLength: 0, + pageNumber: -1, + isTeamMember: false, + isPublic: true, + }, + /** + * 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, + isTeamMember: false, + isPublic: true, + }, + /** + * 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, + isTeamMember: false, + isPublic: true, + }, + /** + * Returns noteList if user is in team + * User is authorized + */ + { + isAuthorized: true, + expectedStatusCode: 200, + expectedMessage: null, + expectedLength: 30, + pageNumber: 1, + isTeamMember: true, + isPublic: false, + }, + /** + * Returns error message with no items if user is not in team + * User is authorized + */ + { + isAuthorized: true, + expectedStatusCode: 403, + expectedMessage: 'Permission denied', + expectedLength: 0, + pageNumber: 1, + isTeamMember: false, + isPublic: false, + }, + ])('Get note list', async ({ isAuthorized, expectedStatusCode, expectedMessage, expectedLength, pageNumber, isTeamMember, isPublic }) => { + const portionSize = 49; + let accessToken; + + /** Insert creator */ + const creator = await global.db.insertUser(); + + /** Insert Note */ + const parentNote = await global.db.insertNote({ + creatorId: creator.id, + }); + + const noteSetting = await global.db.insertNoteSetting({ + noteId: parentNote.id, + cover: 'DZnvqi63.png', + isPublic: isPublic, + }); + + const randomGuy = await global.db.insertUser(); + + if (isAuthorized) { + accessToken = global.auth(randomGuy.id); + } + + if (isTeamMember) { + await global.api?.fakeRequest({ + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/join/${noteSetting.invitationHash}`, + }); + } + + 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 49507d2f..6ca1eac2 100644 --- a/src/presentation/http/router/noteList.ts +++ b/src/presentation/http/router/noteList.ts @@ -1,7 +1,9 @@ import type { FastifyPluginCallback } from 'fastify'; import type NoteService from '@domain/service/note.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 type { NoteInternalId } from '@domain/entities/note.js'; /** * Interface for the noteList router. @@ -12,6 +14,11 @@ interface NoteListRouterOptions { */ noteService: NoteService; + /** + * Note Settings service instance + */ + noteSettingsService: NoteSettingsService; + } /** @@ -22,6 +29,7 @@ interface NoteListRouterOptions { */ const NoteListRouter: FastifyPluginCallback = (fastify, opts, done) => { const noteService = opts.noteService; + const noteSettingsService = opts.noteSettingsService; /** * Get note list ordered by time of last visit @@ -77,6 +85,80 @@ 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: [ + 'authRequired', + ], + }, + schema: { + params: { + notePublicId: { + $ref: 'NoteSchema#/properties/id', + }, + }, + querystring: { + page: { + type: 'number', + minimum: 1, + maximum: 30, + }, + }, + response: { + '2xx': { + description: 'Query notelist', + properties: { + items: { + type: 'array', + items: { $ref: 'NoteSchema#' }, + }, + }, + }, + }, + }, + }, async (request, reply) => { + const { parentNoteId } = request.params; + const userId = request.userId as number; + const { page } = request.query; + + /** + * Fetching note settings from noteSetting service + */ + const noteSettings = await noteSettingsService.getNoteSettingsByNoteId(parentNoteId); + + if (!noteSettings.isPublic) { + const isTeamMember = noteSettings.team?.find(team => team.userId === userId); + + /** + * Checks if the user is a member of the team + */ + if (!isTeamMember) { + return reply.forbidden(); + } + } + 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..f90aec6f 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 parentNoteId - 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..7a1457db 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -5,6 +5,7 @@ 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 type { NoteHistoryModel } from './noteHistory.js'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -75,6 +76,7 @@ export default class NoteSequelizeStorage { public historyModel: typeof NoteHistoryModel | null = null; + public relationsModel: typeof NoteRelationsModel | null = null; /** * Database instance */ @@ -155,6 +157,15 @@ export default class NoteSequelizeStorage { }); }; + public createAssociationWithNoteRelationsModel(model: ModelStatic): void { + this.relationsModel = model; + + this.model.hasMany(this.relationsModel, { + foreignKey: 'noteId', + as: 'noteRelations', + }); + } + /** * Insert note to database * @param options - note creation options @@ -346,4 +357,63 @@ 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 reply = await this.model.findAll({ + include: [ + { + model: this.relationsModel, + as: 'noteRelations', + where: { + parentId, + }, + duplicating: false, + attributes: [], + }, + { + model: this.settingsModel, + as: 'noteSettings', + attributes: ['cover'], + duplicating: false, + }, + ], + order: [[ + { + model: this.relationsModel, + as: 'noteRelations', + }, + 'id', + 'DESC', + ]], + offset: offset, + limit: 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, + }; + }); + } } diff --git a/src/repository/storage/postgres/orm/sequelize/teams.ts b/src/repository/storage/postgres/orm/sequelize/teams.ts index 5dfc0cd5..cfaac841 100644 --- a/src/repository/storage/postgres/orm/sequelize/teams.ts +++ b/src/repository/storage/postgres/orm/sequelize/teams.ts @@ -192,7 +192,7 @@ export default class TeamsSequelizeStorage { return await this.model.findAll({ where: { noteId }, - attributes: ['id', 'role'], + attributes: ['id', 'role', 'userId'], include: { model: this.userModel, as: 'user',