Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(recent-notes): save and delete note visits on the appropriate routes #241

Merged
merged 9 commits into from
Apr 7, 2024
12 changes: 5 additions & 7 deletions src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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';
import NoteVisitsService from './service/noteVisits.js';

/**
* Interface for initiated services
Expand Down Expand Up @@ -49,9 +49,9 @@ export interface DomainServices {
fileUploaderService: FileUploaderService,

/**
* Note Visits service instance
* Note visits service instance
*/
noteVisitsService: NoteVisitsService
noteVisitsService: NoteVisitsService;
}

/**
Expand All @@ -61,16 +61,14 @@ export interface DomainServices {
* @param appConfig - app config
*/
export function init(repositories: Repositories, appConfig: AppConfig): DomainServices {
const noteService = new NoteService(repositories.noteRepository, repositories.noteRelationsRepository);

const noteService = new NoteService(repositories.noteRepository, repositories.noteRelationsRepository, repositories.noteVisitsRepository);
const noteVisitsService = new NoteVisitsService(repositories.noteVisitsRepository);
const authService = new AuthService(
appConfig.auth.accessSecret,
appConfig.auth.accessExpiresIn,
appConfig.auth.refreshExpiresIn,
repositories.userSessionRepository
);
const noteVisitsService = new NoteVisitsService(repositories.noteVisitsRepository);

const editorToolsService = new EditorToolsService(repositories.editorToolsRepository);

const sharedServices = {
Expand Down
10 changes: 9 additions & 1 deletion src/domain/service/note.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Note, NoteInternalId, NotePublicId } from '@domain/entities/note.js';
import type NoteRepository from '@repository/note.repository.js';
import type NoteVisitsRepository from '@repository/noteVisits.repository.js';
import { createPublicId } from '@infrastructure/utils/id.js';
import { DomainError } from '@domain/entities/DomainError.js';
import type NoteRelationsRepository from '@repository/noteRelations.repository.js';
Expand All @@ -20,6 +21,11 @@ export default class NoteService {
*/
public noteRelationsRepository: NoteRelationsRepository;

/**
* Note visits repository
*/
public noteVisitsRepository: NoteVisitsRepository;

/**
* 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
Expand All @@ -31,10 +37,12 @@ export default class NoteService {
*
* @param noteRepository - note repository
* @param noteRelationsRepository - note relationship repository
* @param noteVisitsRepository - note visits repository
*/
constructor(noteRepository: NoteRepository, noteRelationsRepository: NoteRelationsRepository) {
constructor(noteRepository: NoteRepository, noteRelationsRepository: NoteRelationsRepository, noteVisitsRepository: NoteVisitsRepository) {
this.noteRepository = noteRepository;
this.noteRelationsRepository = noteRelationsRepository;
this.noteVisitsRepository = noteVisitsRepository;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/domain/service/noteVisits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,13 @@ export default class NoteVisitsService {
public async saveVisit(noteId: NoteInternalId, userId: User['id']): Promise<NoteVisit> {
return await this.noteVisitsRepository.saveVisit(noteId, userId);
};

/**
* Deletes all visits of the note when a note is deleted
*
* @param noteId - note internal id
*/
public async deleteNoteVisits(noteId: NoteInternalId): Promise<boolean> {
return await this.noteVisitsRepository.deleteNoteVisits(noteId);
}
}
1 change: 1 addition & 0 deletions src/presentation/http/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export default class HttpApi implements Api {
prefix: '/note',
noteService: domainServices.noteService,
noteSettingsService: domainServices.noteSettingsService,
noteVisitsService: domainServices.noteVisitsService,
});

await this.server?.register(NoteListRouter, {
Expand Down
37 changes: 36 additions & 1 deletion src/presentation/http/router/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useNoteSettingsResolver from '../middlewares/noteSettings/useNoteSettings
import useMemberRoleResolver from '../middlewares/noteSettings/useMemberRoleResolver.js';
import { MemberRole } from '@domain/entities/team.js';
import { type NotePublic, definePublicNote } from '@domain/entities/notePublic.js';
import type NoteVisitsService from '@domain/service/noteVisits.js';

/**
* Interface for the note router.
Expand All @@ -22,6 +23,11 @@ interface NoteRouterOptions {
* Note Settings service instance
*/
noteSettingsService: NoteSettingsService,

/**
* Note visits service instance
*/
noteVisitsService: NoteVisitsService;
}

/**
Expand All @@ -37,6 +43,7 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
*/
const noteService = opts.noteService;
const noteSettingsService = opts.noteSettingsService;
const noteVisitsService = opts.noteVisitsService;

/**
* Prepare note id resolver middleware
Expand Down Expand Up @@ -111,14 +118,26 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
],
}, async (request, reply) => {
const { note } = request;
const noteId = request.note?.id as number;
const { memberRole } = request;
const { userId } = request;

/**
* Check if note exists
*/
if (note === null) {
return reply.notFound('Note not found');
}

/**
* Check if user is authorized
*
* @todo use event bus to save note visits
*/
if (userId !== null) {
await noteVisitsService.saveVisit(noteId, userId);
elizachi marked this conversation as resolved.
Show resolved Hide resolved
neSpecc marked this conversation as resolved.
Show resolved Hide resolved
}

const parentId = await noteService.getParentNoteIdByNoteId(note.id);

const parentNote = parentId !== null ? definePublicNote(await noteService.getNoteById(parentId)) : undefined;
Expand Down Expand Up @@ -367,6 +386,13 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don

const isDeleted = await noteService.unlinkParent(noteId);

/**
* Delete all visits of the note
*
* @todo use event bus to delete note visits
*/
await noteVisitsService.deleteNoteVisits(noteId);

/**
* Check if parent relation was successfully deleted
*/
Expand Down Expand Up @@ -417,7 +443,7 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
},
}, async (request, reply) => {
const params = request.params;

const { userId } = request;
elizachi marked this conversation as resolved.
Show resolved Hide resolved
const note = await noteService.getNoteByHostname(params.hostname);

/**
Expand All @@ -427,6 +453,15 @@ const NoteRouter: FastifyPluginCallback<NoteRouterOptions> = (fastify, opts, don
return reply.notFound('Note not found');
}

/**
* Save note visit if user is authorized
*
* @todo use event bus to save note visits
*/
if (userId !== null) {
await noteVisitsService.saveVisit(note.id, userId);
}

/**
* By default, unauthorized user can not edit the note
*/
Expand Down
9 changes: 9 additions & 0 deletions src/repository/noteVisits.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,13 @@ export default class NoteVisitsRepository {
public async saveVisit(noteId: NoteInternalId, userId: User['id']): Promise<NoteVisit> {
return await this.storage.saveVisit(noteId, userId);
}

/**
* Deletes all visits of the note when a note is deleted
*
* @param noteId - note internal id
*/
public async deleteNoteVisits(noteId: NoteInternalId): Promise<boolean> {
return await this.storage.deleteNoteVisits(noteId);
}
}
57 changes: 49 additions & 8 deletions src/repository/storage/postgres/orm/sequelize/noteVisits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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 { literal } from 'sequelize';
import { Model, DataTypes } from 'sequelize';
import type Orm from '@repository/storage/postgres/orm/sequelize/index.js';
import { NoteModel } from './note.js';
Expand Down Expand Up @@ -117,18 +118,58 @@ export default class NoteVisitsSequelizeStorage {
* 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,
}, {
conflictWhere: {
const existingVisit = await this.model.findOne({
where: {
noteId,
userId,
},
returning: true,
});

return recentVisit;
let updatedVisits: NoteVisit[];
let _;

if (existingVisit === null) {
return await this.model.create({
noteId,
userId,
/**
* 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
*/
visitedAt: literal('CLOCK_TIMESTAMP()') as unknown as string,
});
} else {
[_, updatedVisits] = await this.model.update({
visitedAt: literal('CLOCK_TIMESTAMP()') as unknown as string,
}, {
where: {
noteId,
userId,
},
returning: true,
});
}

return updatedVisits[0];
}

/**
* Deletes all visits of the note when a note is deleted
*
* @param noteId - note internal id
*/
public async deleteNoteVisits(noteId: NoteInternalId): Promise<boolean> {
const deletedNoteVisits = await this.model.destroy({
where: {
noteId,
},
});

if (deletedNoteVisits) {
return true;
}

return false;
}
}
4 changes: 2 additions & 2 deletions src/tests/utils/database-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,10 @@ export default class DatabaseHelpers {
*
* @param visit object which contain all info about noteVisit (visitedAt is optional)
*
* if no visitedAt passed, then visited_at would have CURRENT_DATE value
* if no visitedAt passed, then visited_at would have CLOCK_TIMESTAMP() value
*/
public async insertNoteVisit(visit: NoteVisitCreationAttributes): Promise<NoteVisit> {
const visitedAt = visit.visitedAt ?? 'NOW()';
const visitedAt = visit.visitedAt ?? 'CLOCK_TIMESTAMP()';


const [results, _] = await this.orm.connection.query(`INSERT INTO public.note_visits ("user_id", "note_id", "visited_at")
Expand Down
Loading