Skip to content

Commit

Permalink
Merge pull request #269 from codex-team/note-history
Browse files Browse the repository at this point in the history
feat(noteHistory): note history implementation
  • Loading branch information
e11sy authored Jul 24, 2024
2 parents b393213 + 4bb9cac commit 95a67bd
Show file tree
Hide file tree
Showing 16 changed files with 1,024 additions and 57 deletions.
30 changes: 30 additions & 0 deletions migrations/tenant/[email protected]
Original file line number Diff line number Diff line change
@@ -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);
50 changes: 50 additions & 0 deletions src/domain/entities/noteHistory.ts
Original file line number Diff line number Diff line change
@@ -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<NoteHistoryRecord, 'id' | 'createdAt'>;

/**
* Meta data of the note history record
* Used for presentation of the note history record in web
*/
export type NoteHistoryMeta = Omit<NoteHistoryRecord, 'content' | 'noteId' | 'tools'>;

/**
* Public note history record with note public id instead of note internal id
*/
export type NoteHistoryPublic = Omit<NoteHistoryRecord, 'noteId'> & { noteId: NotePublicId };
2 changes: 1 addition & 1 deletion src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
115 changes: 112 additions & 3 deletions src/domain/service/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand All @@ -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);

Expand Down Expand Up @@ -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<Note> {
public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], noteTools: Note['tools'], userId: User['id']): Promise<Note> {
/**
* 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) {
Expand Down Expand Up @@ -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<boolean> {
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<NoteHistoryMeta[]> {
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<NoteHistoryPublic> {
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;
}
}
2 changes: 1 addition & 1 deletion src/domain/service/noteSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User['id'] | undefined> {
return await this.teamRepository.removeTeamMemberByUserIdAndNoteId(userId, noteId);
Expand Down
3 changes: 3 additions & 0 deletions src/presentation/http/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 95a67bd

Please sign in to comment.