diff --git a/migrations/tenant/0027-note-visits@add-note-visits-table.sql b/migrations/tenant/0027-note-visits@add-note-visits-table.sql new file mode 100644 index 00000000..074ad509 --- /dev/null +++ b/migrations/tenant/0027-note-visits@add-note-visits-table.sql @@ -0,0 +1,17 @@ +-- +-- Name: note_visits; Type: TABLE; Schema: public; Owner: codex +-- + +CREATE TABLE IF NOT EXISTS public.note_visits ( + id SERIAL PRIMARY KEY, + user_id integer NOT NULL REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE, + note_id integer NOT NULL REFERENCES public.notes(id) ON UPDATE CASCADE ON DELETE CASCADE, + "visited_at" timestamp with time zone NOT NULL +); + +-- +-- Name note_visits note_visits_user_id_idx; Type: INDEX; Schema: public; Owner: codex +-- +CREATE UNIQUE INDEX note_visits_user_id_idx ON public.note_visits (user_id); + +ALTER TABLE public.note_visits OWNER TO codex; \ No newline at end of file diff --git a/src/domain/entities/noteVisit.ts b/src/domain/entities/noteVisit.ts new file mode 100644 index 00000000..5c8f7996 --- /dev/null +++ b/src/domain/entities/noteVisit.ts @@ -0,0 +1,27 @@ +import type { NoteInternalId } from '@domain/entities/note.ts'; +import type User from '@domain/entities/user.ts'; + +/** + * NoteVisit is used to store data about the last interaction between the user and the note + */ +export default interface NoteVisit { + /** + * Unique property identifier + */ + id: number, + + /** + * Internal id of the note + */ + noteId: NoteInternalId, + + /** + * Id of the user + */ + userId: User['id'], + + /** + * Time when note was visited for the last time (timestamp with timezone) + */ + visitedAt: string, +} \ No newline at end of file diff --git a/src/domain/index.ts b/src/domain/index.ts index ce679ac4..9c5861d9 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -8,6 +8,7 @@ import UserService from '@domain/service/user.js'; import AIService from './service/ai.js'; import EditorToolsService from '@domain/service/editorTools.js'; import FileUploaderService from './service/fileUploader.service.js'; +import NoteVisitsService from '@domain/service/noteVisits.js'; /** * Interface for initiated services @@ -42,12 +43,21 @@ export interface DomainServices { * AI service instance */ aiService: AIService + + /** + * Editor tools service instance + */ editorToolsService: EditorToolsService, /** * File uploader service instance */ - fileUploaderService: FileUploaderService + fileUploaderService: FileUploaderService, + + /** + * Note Visits service instance + */ + noteVisitsService: NoteVisitsService } /** @@ -59,6 +69,7 @@ export interface DomainServices { export function init(repositories: Repositories, appConfig: AppConfig): DomainServices { const noteListService = new NoteListService(repositories.noteRepository); const noteService = new NoteService(repositories.noteRepository, repositories.noteRelationsRepository); + const noteVisitsService = new NoteVisitsService(repositories.noteVisitsRepository); const authService = new AuthService( appConfig.auth.accessSecret, @@ -92,5 +103,6 @@ export function init(repositories: Repositories, appConfig: AppConfig): DomainSe authService, aiService, editorToolsService, + noteVisitsService, }; } diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 635931a7..f5b76577 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -182,7 +182,7 @@ export default class NoteService { } /** - * @todo: add check, that new parent is not in childNotes to avoid circular references + * @todo add check, that new parent is not in childNotes to avoid circular references */ return await this.noteRelationsRepository.updateNoteRelationById(noteId, parentNote.id); }; diff --git a/src/domain/service/noteVisits.ts b/src/domain/service/noteVisits.ts new file mode 100644 index 00000000..6edefa6c --- /dev/null +++ b/src/domain/service/noteVisits.ts @@ -0,0 +1,34 @@ +import type { NoteInternalId } from '@domain/entities/note'; +import type User from '@domain/entities/user.js'; +import type NoteVisit from '@domain/entities/noteVisit.js'; +import type NoteVisitsRepository from '@repository/noteVisits.repository'; + +/** + * Note Visits service, which will store latest note visit + * it is used to display recent notes for each user + */ +export default class NoteVisitsService { + /** + * Note Visits repository + */ + public noteVisitsRepository: NoteVisitsRepository; + + /** + * NoteVisits service constructor + * + * @param noteVisitRepository - note Visits repository + */ + constructor(noteVisitRepository: NoteVisitsRepository) { + this.noteVisitsRepository = noteVisitRepository; + } + + /** + * Updates existing noteVisit's visitedAt or creates new record if user opens note for the first time + * + * @param noteId - note internal id + * @param userId - id of the user + */ + public async saveVisit(noteId: NoteInternalId, userId: User['id']): Promise { + return await this.noteVisitsRepository.saveVisit(noteId, userId); + }; +} \ No newline at end of file diff --git a/src/repository/index.ts b/src/repository/index.ts index fe4172f6..f1117e77 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -21,6 +21,8 @@ import { S3Storage } from './storage/s3/index.js'; import FileStorage from './storage/file.storage.js'; 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'; /** * Interface for initiated repositories @@ -55,6 +57,10 @@ export interface Repositories { * AI repository instance */ aiRepository: AIRepository + + /** + * Editor tools repository instance + */ editorToolsRepository: EditorToolsRepository, /** @@ -71,6 +77,11 @@ export interface Repositories { * Object repository instance */ objectStorageRepository: ObjectStorageRepository, + + /** + * Note Visits repository instance + */ + noteVisitsRepository: NoteVisitsRepository, } /** @@ -107,6 +118,8 @@ export async function init(orm: Orm, s3Config: S3StorageConfig): Promise { + return await this.storage.saveVisit(noteId, userId); + } +} \ No newline at end of file diff --git a/src/repository/storage/noteVisits.storage.ts b/src/repository/storage/noteVisits.storage.ts new file mode 100644 index 00000000..f63a12a8 --- /dev/null +++ b/src/repository/storage/noteVisits.storage.ts @@ -0,0 +1,5 @@ +import NoteVisitsSequelizeStorage from './postgres/orm/sequelize/noteVisits.js'; +/** + * Current note visits storage + */ +export default NoteVisitsSequelizeStorage; diff --git a/src/repository/storage/postgres/orm/sequelize/noteVisits.ts b/src/repository/storage/postgres/orm/sequelize/noteVisits.ts new file mode 100644 index 00000000..d9dc8122 --- /dev/null +++ b/src/repository/storage/postgres/orm/sequelize/noteVisits.ts @@ -0,0 +1,132 @@ +import type NoteVisit from '@domain/entities/noteVisit.js'; +import type User from '@domain/entities/user.js'; +import type { Sequelize, InferAttributes, InferCreationAttributes, CreationOptional, ModelStatic } from 'sequelize'; +import { Model, DataTypes } 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 { NoteInternalId } from '@domain/entities/note.js'; + + +/** + * + */ +export class NoteVisitsModel extends Model, InferCreationAttributes> { + public declare id: CreationOptional; + + public declare noteId: NoteVisit['noteId']; + + public declare userId: NoteVisit['userId']; + + public declare visitedAt: NoteVisit['visitedAt']; +} + +/** + * + */ +export default class NoteVisitsSequelizeStorage { + public model: typeof NoteVisitsModel; + + public userModel: typeof UserModel | null = null; + + public noteModel: typeof NoteModel | null = null; + + private readonly database: Sequelize; + + private readonly tableName = 'note_visits'; + + /** + * + * @param ormInstance - ORM instance + */ + constructor({ connection }: Orm) { + this.database = connection; + + this.model = NoteVisitsModel.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + noteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: UserModel.tableName, + key: 'id', + }, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: NoteModel.tableName, + key: 'id', + }, + }, + visitedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, { + tableName: this.tableName, + sequelize: this.database, + }); + }; + + /** + * 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, + }); + } + + /** + * Updates existing noteVisit's visitedAt or creates new record if user opens note for the first time + * + * @param noteId - note internal id + * @param userId - id of the user + * @returns created or updated NoteVisit + */ + public async saveVisit(noteId: NoteInternalId, userId: User['id']): Promise { + /** + * If user has already visited note, then existing record will be updated + * If user is visiting note for the first time, new record will be created + */ + /* eslint-disable-next-line */ + const [recentVisit, _] = await this.model.upsert({ + noteId, + userId, + visitedAt: 'CURRENT TIME', + }, { + conflictWhere: { + noteId, + userId, + }, + returning: true, + }); + + return recentVisit; + } +} \ No newline at end of file