diff --git a/migrations/tenant/0032-note-history@add-note-history-table.sql b/migrations/tenant/0032-note-history@add-note-history-table.sql new file mode 100644 index 00000000..7cc429f3 --- /dev/null +++ b/migrations/tenant/0032-note-history@add-note-history-table.sql @@ -0,0 +1,30 @@ +-- +-- Name: note_history; Type: TABLE; Schema: public; Owner: codex +-- + +CREATE TABLE IF NOT EXISTS public.note_history ( + id SERIAL PRIMARY KEY, + note_id integer NOT NULL, + user_id integer, + created_at TIMESTAMP NOT NULL, + content json, + tools jsonb +); + +-- +-- Name: note_history note_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: codex +-- +ALTER TABLE public.note_history DROP CONSTRAINT IF EXISTS note_id_fkey; +ALTER TABLE public.note_history + ADD CONSTRAINT note_id_fkey FOREIGN KEY (note_id) REFERENCES public.notes(id) + ON UPDATE CASCADE ON DELETE CASCADE; + +-- +-- Name: note_history user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: codex +-- +ALTER TABLE public.note_history DROP CONSTRAINT IF EXISTS user_id_fkey; +ALTER TABLE public.note_history + ADD CONSTRAINT user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) + ON UPDATE CASCADE ON DELETE CASCADE; + +CREATE INDEX note_history_note_id_idx ON public.note_history (note_id); diff --git a/src/domain/entities/noteHistory.ts b/src/domain/entities/noteHistory.ts new file mode 100644 index 00000000..24088c6f --- /dev/null +++ b/src/domain/entities/noteHistory.ts @@ -0,0 +1,50 @@ +import type { NoteInternalId, Note, NotePublicId } from './note.js'; +import type User from './user.js'; + +export interface NoteHistoryRecord { + /** + * Unique identified of note history record + */ + id: number; + + /** + * Id of the note whose content history is stored + */ + noteId: NoteInternalId; + + /** + * User that updated note content + */ + userId: User['id']; + + /** + * Timestamp of note update + */ + createdAt: string; + + /** + * Version of note content + */ + content: Note['content']; + + /** + * Note tools of current version of note content + */ + tools: Note['tools']; +} + +/** + * Part of note entity used to create new note + */ +export type NoteHistoryCreationAttributes = Omit; + +/** + * Meta data of the note history record + * Used for presentation of the note history record in web + */ +export type NoteHistoryMeta = Omit; + +/** + * Public note history record with note public id instead of note internal id + */ +export type NoteHistoryPublic = Omit & { noteId: NotePublicId }; diff --git a/src/domain/index.ts b/src/domain/index.ts index aaf86f8a..44ed849b 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -63,7 +63,7 @@ export function init(repositories: Repositories, appConfig: AppConfig): DomainSe /** * @todo use shared methods for uncoupling repositories unrelated to note service */ - const noteService = new NoteService(repositories.noteRepository, repositories.noteRelationsRepository, repositories.noteVisitsRepository, repositories.editorToolsRepository); + const noteService = new NoteService(repositories.noteRepository, repositories.noteRelationsRepository, repositories.noteVisitsRepository, repositories.editorToolsRepository, repositories.noteHistoryRepository); const noteVisitsService = new NoteVisitsService(repositories.noteVisitsRepository); const authService = new AuthService( appConfig.auth.accessSecret, diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 0d677c32..ca655464 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -7,6 +7,8 @@ import type NoteRelationsRepository from '@repository/noteRelations.repository.j import type EditorToolsRepository from '@repository/editorTools.repository.js'; import type User from '@domain/entities/user.js'; import type { NoteList } from '@domain/entities/noteList.js'; +import type NoteHistoryRepository from '@repository/noteHistory.repository.js'; +import type { NoteHistoryMeta, NoteHistoryRecord, NoteHistoryPublic } from '@domain/entities/noteHistory.js'; /** * Note service @@ -32,24 +34,36 @@ export default class NoteService { */ public editorToolsRepository: EditorToolsRepository; + /** + * Note history repository + */ + public noteHistoryRepository: NoteHistoryRepository; + /** * Number of the notes to be displayed on one page * it is used to calculate offset and limit for getting notes that the user has recently opened */ private readonly noteListPortionSize = 30; + /** + * Constant used for checking that content changes are valuable enough to save updated note content to the history + */ + private readonly valuableContentChangesLength = 100; + /** * Note service constructor * @param noteRepository - note repository * @param noteRelationsRepository - note relationship repository * @param noteVisitsRepository - note visits repository - * @param editorToolsRepository - editor tools repository + * @param editorToolsRepository - editor tools repositoryn + * @param noteHistoryRepository - note history repository */ - constructor(noteRepository: NoteRepository, noteRelationsRepository: NoteRelationsRepository, noteVisitsRepository: NoteVisitsRepository, editorToolsRepository: EditorToolsRepository) { + constructor(noteRepository: NoteRepository, noteRelationsRepository: NoteRelationsRepository, noteVisitsRepository: NoteVisitsRepository, editorToolsRepository: EditorToolsRepository, noteHistoryRepository: NoteHistoryRepository) { this.noteRepository = noteRepository; this.noteRelationsRepository = noteRelationsRepository; this.noteVisitsRepository = noteVisitsRepository; this.editorToolsRepository = editorToolsRepository; + this.noteHistoryRepository = noteHistoryRepository; } /** @@ -68,6 +82,16 @@ export default class NoteService { tools, }); + /** + * First note save always goes to the note history + */ + await this.noteHistoryRepository.createNoteHistoryRecord({ + content, + userId: creatorId, + noteId: note.id, + tools, + }); + if (parentPublicId !== undefined) { const parentNote = await this.getNoteByPublicId(parentPublicId); @@ -117,8 +141,21 @@ export default class NoteService { * @param id - note internal id * @param content - new content * @param noteTools - tools which are used in note + * @param userId - id of the user that made changes */ - public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], noteTools: Note['tools']): Promise { + public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], noteTools: Note['tools'], userId: User['id']): Promise { + /** + * If content changes are valuable, they will be saved to note history + */ + if (await this.areContentChangesSignificant(id, content)) { + await this.noteHistoryRepository.createNoteHistoryRecord({ + content, + userId: userId, + noteId: id, + tools: noteTools, + }); + }; + const updatedNote = await this.noteRepository.updateNoteContentAndToolsById(id, content, noteTools); if (updatedNote === null) { @@ -326,4 +363,76 @@ export default class NoteService { return note.publicId; } + + /** + * Check if content changes are valuable enough to save currently changed note to the history + * The sufficiency of changes is determined by the length of the content change + * @param noteId - id of the note that is currently changed + * @param content - updated note content + * @returns - boolean, true if changes are valuable enough, false otherwise + */ + public async areContentChangesSignificant(noteId: NoteInternalId, content: Note['content']): Promise { + const currentlySavedNoteContent = (await this.noteHistoryRepository.getLastContentVersion(noteId)); + + if (currentlySavedNoteContent === undefined) { + throw new DomainError('No history for the note found'); + } + + const currentContentLength = currentlySavedNoteContent.blocks.reduce((length, block) => { + length += JSON.stringify(block.data).length; + + return length; + }, 0); + + const patchedContentLength = content.blocks.reduce((length, block) => { + length += JSON.stringify(block.data).length; + + return length; + }, 0); + + if (Math.abs(currentContentLength - patchedContentLength) >= this.valuableContentChangesLength) { + return true; + } + + return false; + } + + /** + * Get all note content change history metadata (without actual content) + * Used for preview of all changes of the note content + * @param noteId - id of the note + * @returns - array of metadata of note changes history + */ + public async getNoteHistoryByNoteId(noteId: Note['id']): Promise { + return await this.noteHistoryRepository.getNoteHistoryByNoteId(noteId); + } + + /** + * Get concrete history record of the note + * Used for showing some of the note content versions + * @param id - id of the note history record + * @returns full public note history record or raises domain error if record not found + */ + public async getHistoryRecordById(id: NoteHistoryRecord['id']): Promise { + const noteHistoryRecord = await this.noteHistoryRepository.getHistoryRecordById(id); + + if (noteHistoryRecord === null) { + throw new DomainError('This version of the note not found'); + } + + /** + * Resolve note history record for it to be public + * changes noteId from internal to public + */ + const noteHistoryPublic = { + id: noteHistoryRecord.id, + noteId: await this.getNotePublicIdByInternal(noteHistoryRecord.noteId), + userId: noteHistoryRecord.userId, + content: noteHistoryRecord.content, + tools: noteHistoryRecord.tools, + createdAt: noteHistoryRecord.createdAt, + }; + + return noteHistoryPublic; + } } diff --git a/src/domain/service/noteSettings.ts b/src/domain/service/noteSettings.ts index 841cc610..b6df79bf 100644 --- a/src/domain/service/noteSettings.ts +++ b/src/domain/service/noteSettings.ts @@ -165,7 +165,7 @@ export default class NoteSettingsService { * Remove team member by userId and noteId * @param userId - id of team member * @param noteId - note internal id - * @returns returns userId if team member was deleted and undefined overwise + * @returns returns userId if team member was deleted and undefined otherwise */ public async removeTeamMemberByUserIdAndNoteId(userId: TeamMember['id'], noteId: NoteInternalId): Promise { return await this.teamRepository.removeTeamMemberByUserIdAndNoteId(userId, noteId); diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index c3df80e6..31c1046a 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -19,6 +19,7 @@ import AIRouter from '@presentation/http/router/ai.js'; import EditorToolsRouter from './router/editorTools.js'; import { UserSchema } from './schema/User.js'; import { NoteSchema } from './schema/Note.js'; +import { HistotyRecordShema, HistoryMetaSchema } from './schema/History.js'; import { NoteSettingsSchema } from './schema/NoteSettings.js'; import { OauthSchema } from './schema/OauthSchema.js'; import Policies from './policies/index.js'; @@ -291,6 +292,8 @@ export default class HttpApi implements Api { private addSchema(): void { this.server?.addSchema(UserSchema); this.server?.addSchema(NoteSchema); + this.server?.addSchema(HistotyRecordShema); + this.server?.addSchema(HistoryMetaSchema); this.server?.addSchema(EditorToolSchema); this.server?.addSchema(NoteSettingsSchema); this.server?.addSchema(JoinSchemaParams); diff --git a/src/presentation/http/router/note.test.ts b/src/presentation/http/router/note.test.ts index abb7f452..463f74bc 100644 --- a/src/presentation/http/router/note.test.ts +++ b/src/presentation/http/router/note.test.ts @@ -43,17 +43,80 @@ describe('Note API', () => { }; /** - * Note content mock inserted if no content passed + * Default note content mock + * Used for inserting note content */ const DEFAULT_NOTE_CONTENT = { blocks: [ { id: 'mJDq8YbvqO', - type: listTool.name, + type: headerTool.name, data: { text: 'text', }, }, + { + id: 'DeL0QehzGe', + type: paragraphTool.name, + data: { + text: 'fdgsfdgfdsg', + level: 2, + }, + }, + ], + }; + + /** + * Alternative note content mock + * Used for patching note content + */ + const ALTERNATIVE_NOTE_CONTENT = { + blocks: [ + { + id: 'mJDq8YbvqO', + type: headerTool.name, + data: { + text: 'another text', + }, + }, + { + id: 'DeL0QehzGe', + type: paragraphTool.name, + data: { + text: 'fdgsfdgfdsg', + level: 2, + }, + }, + ], + }; + + /** + * Note tools used in default, alternative and large NOTE_CONTENT constants + */ + const DEFAULT_NOTE_TOOLS = [ + { + name: headerTool.name, + id: headerTool.id, + }, + { + name: paragraphTool.name, + id: paragraphTool.id, + }, + ]; + + /** + * Note content with large text field + * Used for patching note content + */ + const LARGE_NOTE_CONTENT = { + blocks: [ + { + id: 'mJDq8YbvqO', + type: paragraphTool.name, + data: { + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla.', + }, + }, { id: 'DeL0QehzGe', type: headerTool.name, @@ -76,16 +139,7 @@ describe('Note API', () => { /** Create test note */ const note = await global.db.insertNote({ creatorId: user.id, - tools: [ - { - name: headerTool.name, - id: headerTool.id, - }, - { - name: paragraphTool.name, - id: paragraphTool.id, - }, - ], + tools: DEFAULT_NOTE_TOOLS, }); /** Create test note settings */ @@ -182,16 +236,7 @@ describe('Note API', () => { /** Create test note */ const note = await global.db.insertNote({ creatorId: creator.id, - tools: [ - { - name: headerTool.name, - id: headerTool.id, - }, - { - name: paragraphTool.name, - id: paragraphTool.id, - }, - ], + tools: DEFAULT_NOTE_TOOLS, }); /** Create test note settings */ @@ -701,6 +746,74 @@ describe('Note API', () => { test.todo('Returns 400 when parentId has incorrect characters and length'); }); + describe('POST and PATCH note correctly save note history records', () => { + const newContentWithSmallChanges = ALTERNATIVE_NOTE_CONTENT; + const newContentWithSignificantChanges = LARGE_NOTE_CONTENT; + + test.each([ + /** + * Patching note content with small changes + * History should have only one record inserted on note creation + */ + { + newNoteContent: newContentWithSmallChanges, + historyRecordAdded: false, + }, + /** + * Patching note content with large changes + * History should have two records, inserted on note creation and on note patch + */ + { + newNoteContent: newContentWithSignificantChanges, + historyRecordAdded: true, + }, + ])('On note creation and note updates history records saves correctly', async ({ newNoteContent, historyRecordAdded }) => { + const user = await global.db.insertUser(); + + const accessToken = global.auth(user.id); + + let response = await global.api?.fakeRequest({ + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + }, + body: { + content: DEFAULT_NOTE_CONTENT, + tools: DEFAULT_NOTE_TOOLS, + }, + url: '/note', + }); + + const noteId = response?.json().id; + + response = await global.api?.fakeRequest({ + method: 'PATCH', + headers: { + authorization: `Bearer ${accessToken}`, + }, + body: { + content: newNoteContent, + tools: DEFAULT_NOTE_TOOLS, + }, + url: `/note/${noteId}`, + }); + + response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${noteId}/history`, + }); + + if (historyRecordAdded) { + expect(response?.json().noteHistoryMeta).toHaveLength(2); + } else { + expect(response?.json().noteHistoryMeta).toHaveLength(1); + } + }); + }); + describe('POST /note', () => { test('Should correctly save relation to parent note if parentId passed', async () => { const user = await global.db.insertUser(); @@ -763,16 +876,7 @@ describe('Note API', () => { * Specified extra tools */ { - noteTools: [ - { - name: headerTool.name, - id: headerTool.id, - }, - { - name: listTool.name, - id: listTool.id, - }, - ], + noteTools: DEFAULT_NOTE_TOOLS, noteContent: { blocks: [ { @@ -1665,7 +1769,7 @@ describe('Note API', () => { }); describe('PATCH /note/:notePublicId', () => { - const tools = [headerTool, listTool]; + const tools = [headerTool, paragraphTool]; test.each([ /** @@ -1694,16 +1798,7 @@ describe('Note API', () => { * All tools specified correctly */ { - noteTools: [ - { - name: headerTool.name, - id: headerTool.id, - }, - { - name: listTool.name, - id: listTool.id, - }, - ], + noteTools: DEFAULT_NOTE_TOOLS, noteContent: DEFAULT_NOTE_CONTENT, expectedStatusCode: 200, expectedMessage: null, @@ -1792,4 +1887,189 @@ describe('Note API', () => { } }); }); + + describe('GET /note/:notePublicId/history', () => { + test.each([ + /** + * User can not edit the note state + * Should return permission denied response + */ + { + authorized: true, + userCanEdit: false, + expectedMessage: 'Permission denied', + }, + /** + * Unauthorized state + * Should return unauthorized response + */ + { + authorized: false, + userCanEdit: true, + expectedMessage: 'You must be authenticated to access this resource', + }, + /** + * Should return array of history records + */ + { + authorized: true, + userCanEdit: true, + expectedMessage: null, + }, + ])('Should return note history preview by note id', async ({ authorized, userCanEdit, expectedMessage }) => { + /** + * Creator of the note + */ + const creator = await global.db.insertUser(); + + /** + * User who wants to check note history + */ + const user = await global.db.insertUser(); + + /** + * Access token of the user who wants to check note history + */ + let userAccessToken: string = ''; + + const note = await global.db.insertNote({ + creatorId: creator.id, + }); + + /** Insert note settings mock */ + await global.db.insertNoteSetting({ + noteId: note.id, + isPublic: false, + }); + + if (authorized) { + userAccessToken = global.auth(user.id); + } + + if (userCanEdit) { + await global.db.insertNoteTeam({ + noteId: 1, + userId: user.id, + role: MemberRole.Write, + }); + } + + /** Insert new note history record mock */ + const history = await global.db.insertNoteHistory({ + noteId: note.id, + userId: creator.id, + content: LARGE_NOTE_CONTENT, + tools: DEFAULT_NOTE_TOOLS, + }); + + /** + * Get note history + */ + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${userAccessToken}`, + }, + url: `/note/${note.publicId}/history`, + }); + + if (expectedMessage !== null) { + expect(response?.json()).toStrictEqual({ message: expectedMessage }); + } else { + expect(response?.json().noteHistoryMeta).toHaveLength(2); + expect(response?.json().noteHistoryMeta[0]).toMatchObject({ + id: 1, + userId: creator.id, + }); + + expect(response?.json().noteHistoryMeta[1]).toMatchObject({ + id: history.id, + userId: history.userId, + createdAt: history.createdAt, + }); + } + }); + }); + + describe('GET /note/:notePublicId/history/:historyId', () => { + test.each([ + /** + * User can not edit the note state + * Should return permission denied response + */ + { + authorized: true, + userCanEdit: false, + expectedMessage: 'Permission denied', + }, + /** + * Unauthorized state + * Should return unauthorized response + */ + { + authorized: false, + userCanEdit: true, + expectedMessage: 'You must be authenticated to access this resource', + }, + /** + * User is authorized and can edit the note + * Should return history record that is inserted + */ + { + authorized: true, + userCanEdit: true, + expectedMessage: null, + }, + ])('Should return certain note history record by it\'s id', async ({ authorized, userCanEdit, expectedMessage }) => { + const creator = await global.db.insertUser(); + + const note = await global.db.insertNote({ creatorId: creator.id }); + + const history = await global.db.insertNoteHistory({ + userId: creator.id, + noteId: note.id, + content: DEFAULT_NOTE_CONTENT, + tools: DEFAULT_NOTE_TOOLS, + }); + + const user = await global.db.insertUser(); + + let accessToken: string = ''; + + if (authorized) { + accessToken = global.auth(user.id); + } + + if (userCanEdit) { + await global.db.insertNoteTeam({ + userId: user.id, + noteId: note.id, + role: MemberRole.Write, + }); + } + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${note.publicId}/history/${history.id}`, + }); + + if (expectedMessage !== null) { + expect(response?.json()).toStrictEqual({ message: expectedMessage }); + } else { + expect(response?.json()).toStrictEqual({ + noteHistoryRecord: { + id: history.id, + userId: history.userId, + noteId: note.publicId, + createdAt: history.createdAt, + content: history.content, + tools: history.tools, + }, + }); + } + }); + }); }); diff --git a/src/presentation/http/router/note.ts b/src/presentation/http/router/note.ts index ed7500d5..6de6b09d 100644 --- a/src/presentation/http/router/note.ts +++ b/src/presentation/http/router/note.ts @@ -11,6 +11,7 @@ import { type NotePublic, definePublicNote } from '@domain/entities/notePublic.j import type NoteVisitsService from '@domain/service/noteVisits.js'; import type EditorToolsService from '@domain/service/editorTools.js'; import type EditorTool from '@domain/entities/editorTools.js'; +import type { NoteHistoryMeta, NoteHistoryPublic, NoteHistoryRecord } from '@domain/entities/noteHistory.js'; /** * Interface for the note router. @@ -362,10 +363,11 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don const noteId = request.note?.id as number; const content = request.body.content; const noteTools = request.body.tools; + const { userId } = request; await noteService.validateNoteTools(noteTools, content); - const note = await noteService.updateNoteContentAndToolsById(noteId, content, noteTools); + const note = await noteService.updateNoteContentAndToolsById(noteId, content, noteTools, userId!); return reply.send({ updatedAt: note.updatedAt, @@ -646,6 +648,118 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don }); }); + /** + * Get note history preview + */ + fastify.get<{ + Params: { + notePublicId: NotePublicId; + }; + Reply: { + noteHistoryMeta: NoteHistoryMeta[]; + } | ErrorResponse; + }>('/:notePublicId/history', { + config: { + policy: [ + 'authRequired', + 'userCanEdit', + ], + }, + schema: { + params: { + notePublicId: { + $ref: 'NoteSchema#/properties/id', + }, + }, + response: { + '2xx': { + type: 'object', + properties: { + noteHistoryMeta: { + type: 'array', + items: { + $ref: 'HistoryMetaSchema', + }, + }, + }, + }, + }, + }, + preHandler: [ + noteResolver, + ], + }, async (request, reply) => { + const { note } = request; + const noteId = request.note?.id as number; + + if (note === null) { + return reply.notAcceptable('Note not found'); + } + + return reply.send({ + noteHistoryMeta: await noteService.getNoteHistoryByNoteId(noteId), + }); + }); + + /** + * Get note history record + */ + fastify.get<{ + Params: { + notePublicId: NotePublicId; + historyId: NoteHistoryRecord['id']; + }; + Reply: { + noteHistoryRecord: NoteHistoryPublic; + } | ErrorResponse; + }>('/:notePublicId/history/:historyId', { + config: { + policy: [ + 'authRequired', + 'userCanEdit', + ], + }, + schema: { + params: { + notePublicId: { + $ref: 'NoteSchema#/properties/id', + }, + historyId: { + $ref: 'NoteHistorySchema#/properties/id', + }, + }, + response: { + '2xx': { + type: 'object', + properties: { + noteHistoryRecord: { + $ref: 'NoteHistorySchema#', + }, + }, + }, + }, + }, + preHandler: [ + noteResolver, + ], + }, async (request, reply) => { + const { note } = request; + const historyId = request.params.historyId; + + /** + * Check if note exists + */ + if (note === null) { + return reply.notAcceptable('Note not found'); + } + + const historyRecord = await noteService.getHistoryRecordById(historyId); + + return reply.send({ + noteHistoryRecord: historyRecord, + }); + }); + done(); }; diff --git a/src/presentation/http/schema/History.ts b/src/presentation/http/schema/History.ts new file mode 100644 index 00000000..63cd5d87 --- /dev/null +++ b/src/presentation/http/schema/History.ts @@ -0,0 +1,88 @@ +/** + * Note history record shema used for validation and serialization + */ +export const HistotyRecordShema = { + $id: 'NoteHistorySchema', + type: 'object', + required: [ + 'content', + 'id', + 'userId', + 'createdAt', + 'tools', + ], + properties: { + id: { + description: 'unique note hisotry record identifier', + type: 'number', + }, + noteId: { + description: 'unique note identifier', + type: 'string', + }, + userId: { + description: 'unique user identifier', + type: 'number', + }, + createdAt: { + description: 'time, when note history record was created', + type: 'string', + format: 'date-time', + }, + content: { + description: 'content of certain version of the note', + type: 'object', + properties: { + time: { + type: 'number', + }, + blocks: { + type: 'array', + }, + version: { + type: 'string', + }, + }, + }, + tools: { + description: 'list of editor tools objects "toolName": "toolId" for content displaying', + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + }, + }, + }, + }, +}; + +export const HistoryMetaSchema = { + $id: 'HistoryMetaSchema', + type: 'object', + required: [ + 'id', + 'userId', + 'createdAt', + ], + properties: { + id: { + description: 'unique note hisotry record identifier', + type: 'number', + }, + userId: { + description: 'unique user identifier', + type: 'number', + }, + createdAt: { + description: 'time, when note history record was created', + type: 'string', + format: 'date-time', + }, + }, +}; diff --git a/src/repository/index.ts b/src/repository/index.ts index f30247f9..ed40d01e 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -23,6 +23,8 @@ import FileRepository from './file.repository.js'; import ObjectStorageRepository from './object.repository.js'; import NoteVisitsRepository from './noteVisits.repository.js'; import NoteVisitsStorage from './storage/noteVisits.storage.js'; +import NoteHistoryStorage from './storage/noteHistory.storage.js'; +import NoteHistoryRepository from './noteHistory.repository.js'; /** * Interface for initiated repositories @@ -82,6 +84,8 @@ export interface Repositories { * Note Visits repository instance */ noteVisitsRepository: NoteVisitsRepository; + + noteHistoryRepository: NoteHistoryRepository; } /** @@ -118,6 +122,7 @@ export async function init(orm: Orm, s3Config: S3StorageConfig): Promise { + return await this.storage.createNoteHistoryRecord(noteHistory); + } + + /** + * Gets array of metadata of all saved note history records + * @param noteId - id of the note, whose history we want to see + * @returns array of metadata of the history records, used for informative presentation of history + */ + public async getNoteHistoryByNoteId(noteId: NoteHistoryRecord['noteId']): Promise { + return await this.storage.getNoteHistoryByNoteId(noteId); + } + + /** + * Get concrete history record by it's id + * Used for presentation of certain version of note content saved in history + * @param id - id of the history record + * @returns full history record or null if there is no record with such an id + */ + public async getHistoryRecordById(id: NoteHistoryRecord['id']): Promise { + return await this.storage.getHistoryRecordById(id); + } + + /** + * Gets recent saved history record content + * Used for service to check latest сhanges compared to the last saved record + * @param noteId - id of the note, whose recent history record we want to see + * @returns - latest saved content of the note + */ + public async getLastContentVersion(noteId: NoteHistoryRecord['noteId']): Promise { + return await this.storage.getLastContentVersion(noteId); + } +} diff --git a/src/repository/storage/noteHistory.storage.ts b/src/repository/storage/noteHistory.storage.ts new file mode 100644 index 00000000..61d39c5e --- /dev/null +++ b/src/repository/storage/noteHistory.storage.ts @@ -0,0 +1,6 @@ +import NoteHistorySequelizeStorage from './postgres/orm/sequelize/noteHistory.js'; + +/** + * Current note storage + */ +export default NoteHistorySequelizeStorage; diff --git a/src/repository/storage/postgres/orm/sequelize/note.ts b/src/repository/storage/postgres/orm/sequelize/note.ts index f926cc02..4d97591b 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -6,6 +6,7 @@ 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 */ @@ -73,6 +74,8 @@ export default class NoteSequelizeStorage { */ public visitsModel: typeof NoteVisitsModel | null = null; + public historyModel: typeof NoteHistoryModel | null = null; + /** * Database instance */ @@ -138,7 +141,7 @@ export default class NoteSequelizeStorage { } /** - * create association with note cisits model + * create association with note visits model * @param model - initialized note visits model */ public createAssociationWithNoteVisitsModel(model: ModelStatic): void { diff --git a/src/repository/storage/postgres/orm/sequelize/noteHistory.ts b/src/repository/storage/postgres/orm/sequelize/noteHistory.ts new file mode 100644 index 00000000..1886ce66 --- /dev/null +++ b/src/repository/storage/postgres/orm/sequelize/noteHistory.ts @@ -0,0 +1,170 @@ +import type { CreationOptional, InferAttributes, InferCreationAttributes, Sequelize, ModelStatic } from 'sequelize'; +import { DataTypes, literal, Model } from 'sequelize'; +import type Orm from '@repository/storage/postgres/orm/sequelize/index.js'; +import { NoteModel } from './note.js'; +import { UserModel } from './user.js'; +import type { NoteHistoryCreationAttributes, NoteHistoryRecord, NoteHistoryMeta } from '@domain/entities/noteHistory.js'; + +/** + * Note history model instance + * Represents structure that is stored in the database + */ +export class NoteHistoryModel extends Model, InferCreationAttributes> { + /** + * Unique identified of note history record + */ + public declare id: CreationOptional; + + /** + * Id of the note whose content history is stored + */ + public declare noteId: NoteHistoryRecord['noteId']; + + /** + * User that updated note content + */ + public declare userId: NoteHistoryRecord['userId']; + + /** + * Timestamp of the note update + */ + public declare createdAt: CreationOptional; + + /** + * Certain version of note content + */ + public declare content: NoteHistoryRecord['content']; + + /** + * Note tools of current version of note content + */ + public declare tools: NoteHistoryRecord['tools']; +} + +export default class NoteHistorySequelizeStorage { + public model: typeof NoteHistoryModel; + + public userModel: typeof UserModel | null = null; + + public noteModel: typeof NoteModel | null = null; + + private readonly database: Sequelize; + + private readonly tableName = 'note_history'; + + constructor({ connection }: Orm) { + this.database = connection; + + this.model = NoteHistoryModel.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + noteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: NoteModel, + key: 'id', + }, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: UserModel, + key: 'id', + }, + }, + createdAt: DataTypes.DATE, + content: DataTypes.JSON, + tools: DataTypes.JSONB, + }, { + tableName: this.tableName, + sequelize: this.database, + timestamps: false, + }); + } + + /** + * Creates association with user model + * @param model - initialized note settings model + */ + public createAssociationWithUserModel(model: ModelStatic): void { + this.userModel = model; + + this.model.belongsTo(this.userModel, { + foreignKey: 'userId', + as: this.userModel.tableName, + }); + } + + /** + * Creates association with note model + * @param model - initialized note model + */ + public createAssociationWithNoteModel(model: ModelStatic): void { + this.noteModel = model; + + this.model.belongsTo(this.noteModel, { + foreignKey: 'noteId', + as: this.noteModel.tableName, + }); + } + + /** + * Creates note hisotry record in storage + * @param options - all data used for note history record creation + * @returns - created note history record + */ + public async createNoteHistoryRecord(options: NoteHistoryCreationAttributes): Promise { + return await this.model.create({ + noteId: options.noteId, + userId: options.userId, + content: options.content, + tools: options.tools, + /** + * we should pass to model datatype respectfully to declared in NoteVisitsModel class + * if we will pass just 'CLOCK_TIMESTAMP()' it will be treated by orm just like a string, that is why we should use literal + * but model wants string, this is why we use this cast + */ + createdAt: literal('CLOCK_TIMESTAMP()') as unknown as string, + }); + } + + /** + * Gets array of metadata of all saved note history records + * @param noteId - id of the note, whose history we want to see + * @returns array of metadata of the history records + */ + public async getNoteHistoryByNoteId(noteId: NoteHistoryRecord['noteId']): Promise { + return await this.model.findAll({ + where: { noteId }, + attributes: ['id', 'userId', 'createdAt'], + }); + } + + /** + * Get concrete history record by it's id + * @param id - id of the history record + * @returns full history record or null if there is no record with such an id + */ + public async getHistoryRecordById(id: NoteHistoryRecord['id']): Promise { + return await this.model.findByPk(id); + } + + /** + * Gets recent saved history record content + * @param noteId - id of the note, whose recent history record we want to see + * @returns - latest saved content of the note + */ + public async getLastContentVersion(noteId: NoteHistoryRecord['id']): Promise { + const latestHistory = await this.model.findOne({ + where: { noteId }, + order: [['createdAt', 'DESC']], + }); + + return latestHistory?.content; + } +} diff --git a/src/repository/team.repository.ts b/src/repository/team.repository.ts index 90de8673..fb01f0d3 100644 --- a/src/repository/team.repository.ts +++ b/src/repository/team.repository.ts @@ -61,7 +61,7 @@ export default class TeamRepository { * Remove team member by id * @param userId - id of the team member * @param noteId - note internal id - * @returns returns userId if team member was deleted and undefined overwise + * @returns returns userId if team member was deleted and undefined otherwise */ public async removeTeamMemberByUserIdAndNoteId(userId: TeamMember['id'], noteId: NoteInternalId): Promise { return await this.storage.removeTeamMemberByUserIdAndNoteId(userId, noteId); diff --git a/src/tests/utils/database-helpers.ts b/src/tests/utils/database-helpers.ts index bc474ef9..4d089789 100644 --- a/src/tests/utils/database-helpers.ts +++ b/src/tests/utils/database-helpers.ts @@ -9,6 +9,7 @@ import type { TeamMember } from '@domain/entities/team.ts'; import type EditorTool from '@domain/entities/editorTools.ts'; import type NoteVisit from '@domain/entities/noteVisit.js'; import { nanoid } from 'nanoid'; +import type { NoteHistoryCreationAttributes, NoteHistoryRecord } from '@domain/entities/noteHistory.js'; /** * Note content mock inserted if no content passed @@ -33,6 +34,20 @@ const DEFAULT_NOTE_CONTENT = { ], }; +/** + * Note tools used in DEFAULT_NOTE_CONTENT constant + */ +const DEFAULT_NOTE_TOOLS = [ + { + name: 'header', + id: '1', + }, + { + name: 'paragraph', + id: '2', + }, +]; + /** * default type for note mock creation attributes */ @@ -126,6 +141,7 @@ export default class DatabaseHelpers { /** * Inserts note mock to then db * Automatically adds note creator to note team + * Automatically adds first note history record * @param note - note object which contain all info about note * * If content is not passed, it's value in database would be {} @@ -134,10 +150,10 @@ export default class DatabaseHelpers { public async insertNote(note: NoteMockCreationAttributes): Promise { const content = note.content ?? DEFAULT_NOTE_CONTENT; const publicId = note.publicId ?? createPublicId(); - const tools = note.tools ?? []; + const tools = note.tools ?? DEFAULT_NOTE_TOOLS; const [results, _] = await this.orm.connection.query(`INSERT INTO public.notes ("content", "creator_id", "created_at", "updated_at", "public_id", "tools") - VALUES ('${JSON.stringify(content)}', ${note.creatorId}, CURRENT_DATE, CURRENT_DATE, '${publicId}', '${JSON.stringify(tools)}'::jsonb) + VALUES ('${JSON.stringify(content)}', ${note.creatorId}, CLOCK_TIMESTAMP(), CLOCK_TIMESTAMP(), '${publicId}', '${JSON.stringify(tools)}'::jsonb) RETURNING "id", "content", "creator_id" AS "creatorId", "public_id" AS "publicId", "created_at" AS "createdAt", "updated_at" AS "updatedAt"`, { type: QueryTypes.INSERT, @@ -152,6 +168,13 @@ export default class DatabaseHelpers { role: 1, }); + await this.insertNoteHistory({ + userId: createdNote.creatorId, + noteId: createdNote.id, + content, + tools: DEFAULT_NOTE_TOOLS, + }); + return createdNote; } @@ -171,7 +194,7 @@ export default class DatabaseHelpers { const email = user?.email ?? `${randomPart}@codexmail.com`; const [results, _] = await this.orm.connection.query(`INSERT INTO public.users ("email", "name", "created_at", "editor_tools") - VALUES ('${email}', '${name}', CURRENT_DATE, '${editorTools}'::jsonb) + VALUES ('${email}', '${name}', CLOCK_TIMESTAMP(), '${editorTools}'::jsonb) RETURNING "id", "email", "name", "editor_tools" AS "editorTools", "created_at" AS "createdAt", "photo"`, { type: QueryTypes.INSERT, @@ -186,12 +209,12 @@ export default class DatabaseHelpers { * Inserts user session mock to the db * @param userSession - userSession object which contain all info about userSession (some info is optional) * - * refreshTokenExpiresAt should be given as Postgres DATE string (e.g. `CURRENT_DATE + INTERVAL '1 day'`) + * refreshTokenExpiresAt should be given as Postgres DATE string (e.g. `CLOCK_TIMESTAMP() + INTERVAL '1 day'`) * - * if no refreshTokenExpiresAt passed, it's value in database would be `CURRENT_DATE + INTERVAL '1 day'` + * if no refreshTokenExpiresAt passed, it's value in database would be `CLOCK_TIMESTAMP() + INTERVAL '1 day'` */ public async insertUserSession(userSession: UserSessionMockCreationAttributes): Promise { - const refreshTokerExpiresAt = userSession.refreshTokenExpiresAt ?? `CURRENT_DATE + INTERVAL '1 day')`; + const refreshTokerExpiresAt = userSession.refreshTokenExpiresAt ?? `CLOCK_TIMESTAMP() + INTERVAL '1 day')`; await this.orm.connection.query(`INSERT INTO public.user_sessions ("user_id", "refresh_token", "refresh_toker_expires_at") VALUES (${userSession.userId}, '${userSession.refreshToker}, '${refreshTokerExpiresAt}')`); @@ -278,6 +301,29 @@ export default class DatabaseHelpers { return createdVisit; } + /** + * Inserts note history mock into db + * @param history - object that contains all data needed for history record creation + * @returns created note history record + */ + public async insertNoteHistory(history: NoteHistoryCreationAttributes): Promise { + const [result, _] = await this.orm.connection.query(`INSERT INTO public.note_history ("user_id", "note_id", "created_at", "content", "tools") + VALUES ('${history.userId}', '${history.noteId}', CLOCK_TIMESTAMP(), '${JSON.stringify(history.content)}', '${JSON.stringify(history.tools)}') + RETURNING "id", "note_id" as "noteId", "user_id" as "userId", "created_at" as "createdAt", "content", "tools"`, + { + type: QueryTypes.INSERT, + returning: true, + }); + + const createdHistory = result[0]; + + /** + * JSON cast is needed since in router it happens by default (and in test we always use response.json()) + * Overwhise model createdAt would be treated as Date object + */ + return JSON.parse(JSON.stringify(createdHistory)); + } + /** * Truncates all tables and restarts all autoincrement sequences */