diff --git a/migrations/tenant/0006-note-settings@rename-enable-to-ispublic.sql b/migrations/tenant/0006-note-settings@rename-enable-to-ispublic.sql index f4d6b8c6..22602c03 100644 --- a/migrations/tenant/0006-note-settings@rename-enable-to-ispublic.sql +++ b/migrations/tenant/0006-note-settings@rename-enable-to-ispublic.sql @@ -1,2 +1,9 @@ -ALTER TABLE IF EXISTS public.note_settings - RENAME COLUMN enabled TO is_public; \ No newline at end of file +DO $$ +BEGIN + IF EXISTS(SELECT * + FROM information_schema.columns + WHERE table_name='note_settings' and column_name='enabled') + THEN + ALTER TABLE "public"."note_settings" RENAME COLUMN "enables" TO "is_public"; + END IF; +END $$; diff --git a/src/domain/entities/team.ts b/src/domain/entities/team.ts new file mode 100644 index 00000000..6cf50b31 --- /dev/null +++ b/src/domain/entities/team.ts @@ -0,0 +1,44 @@ +import type { NoteInternalId } from './note.js'; +import type User from './user.js'; + +export enum MemberRole { + /** + * Team member can read and write notes + */ + read = 'read', + + /** + * Team member can only read notes + */ + write = 'write', +} + +/** + * Class representing a team entity + * Team is a relation between note and user, which shows what user can do with note + */ +export interface TeamMember { + /** + * Team relation id + */ + id: number; + + /** + * Note ID + */ + noteId: NoteInternalId; + + /** + * Team member user id + */ + userId: User['id']; + + /** + * Team member role, show what user can do with note + */ + role: MemberRole; +} + +export type Team = TeamMember[]; + +export type TeamMemberCreationAttributes = Omit; diff --git a/src/domain/index.ts b/src/domain/index.ts index d7742b11..8e88151c 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -52,7 +52,7 @@ export interface DomainServices { */ export function init(repositories: Repositories, appConfig: AppConfig): DomainServices { const noteService = new NoteService(repositories.noteRepository); - const noteSettingsService = new NoteSettingsService(repositories.noteSettingsRepository); + const noteSettingsService = new NoteSettingsService(repositories.noteSettingsRepository, repositories.teamRepository); const noteListService = new NoteListService(repositories.noteRepository); diff --git a/src/domain/service/noteSettings.ts b/src/domain/service/noteSettings.ts index a97a1dd4..5da46018 100644 --- a/src/domain/service/noteSettings.ts +++ b/src/domain/service/noteSettings.ts @@ -1,6 +1,9 @@ import type { NoteInternalId } from '@domain/entities/note.js'; import type NoteSettings from '@domain/entities/noteSettings.js'; import type NoteSettingsRepository from '@repository/noteSettings.repository.js'; +import type TeamRepository from '@repository/team.repository.js'; +import type { MemberRole, Team, TeamMember, TeamMemberCreationAttributes } from '@domain/entities/team.js'; +import type User from '@domain/entities/user.js'; /** * Service responsible for Note Settings @@ -9,15 +12,19 @@ export default class NoteSettingsService { /** * Note Settings repository */ - public repository: NoteSettingsRepository; + public noteSettingsRepository: NoteSettingsRepository; + + private readonly teamRepository: TeamRepository; /** * Note Settings service constructor * - * @param repository - note repository + * @param noteSettingsrepository - note settings repository + * @param teamRepository - team repository */ - constructor(repository: NoteSettingsRepository) { - this.repository = repository; + constructor(noteSettingsrepository: NoteSettingsRepository, teamRepository: TeamRepository) { + this.noteSettingsRepository = noteSettingsrepository; + this.teamRepository = teamRepository; } /** @@ -26,7 +33,7 @@ export default class NoteSettingsService { * @param id - note internal id */ public async getNoteSettingsByNoteId(id: NoteInternalId): Promise { - return await this.repository.getNoteSettingsByNoteId(id); + return await this.noteSettingsRepository.getNoteSettingsByNoteId(id); } /** @@ -37,7 +44,7 @@ export default class NoteSettingsService { * @returns added note settings */ public async addNoteSettings(noteId: NoteInternalId, isPublic: boolean = true): Promise { - return await this.repository.addNoteSettings({ + return await this.noteSettingsRepository.addNoteSettings({ noteId: noteId, isPublic: isPublic, }); @@ -51,8 +58,48 @@ export default class NoteSettingsService { * @returns updated note settings */ public async patchNoteSettingsByNoteId(noteId: NoteInternalId, data: Partial): Promise { - const noteSettings = await this.repository.getNoteSettingsByNoteId(noteId); + const noteSettings = await this.noteSettingsRepository.getNoteSettingsByNoteId(noteId); + + return await this.noteSettingsRepository.patchNoteSettingsById(noteSettings.id, data); + } - return await this.repository.patchNoteSettingsById(noteSettings.id, data); + /** + * Get user role in team by user id and note id + * If user is not a member of note, return null + * + * @param userId - user id to check his role + * @param noteId - note id where user should have role + */ + public async getUserRoleByUserIdAndNoteId(userId: User['id'], noteId: NoteInternalId): Promise { + return await this.teamRepository.getUserRoleByUserIdAndNoteId(userId, noteId); + } + + /** + * Get all team members by note id + * + * @param noteId - note id to get all team members + * @returns team members + */ + public async getTeamByNoteId(noteId: NoteInternalId): Promise { + return await this.teamRepository.getByNoteId(noteId); + } + + /** + * Remove team member by id + * + * @param id - team member id + */ + public async removeTeamMemberById(id: TeamMember['id']): Promise { + return await this.teamRepository.removeMemberById(id); + } + + /** + * Creates team member + * + * @param team - data for team member creation + * @returns created team member + */ + public async createTeamMember(team: TeamMemberCreationAttributes): Promise { + return await this.teamRepository.create(team); } } diff --git a/src/presentation/http/router/note.test.ts b/src/presentation/http/router/note.test.ts index b4aa3cd7..c7c1ab1c 100644 --- a/src/presentation/http/router/note.test.ts +++ b/src/presentation/http/router/note.test.ts @@ -14,10 +14,10 @@ describe('Note API', () => { 'createdAt': '2023-10-16T13:49:19.000Z', 'updatedAt': '2023-10-16T13:49:19.000Z', 'noteSettings': { - 'custom_hostname': 'codex.so', - 'enabled': true, + 'customHostname': 'codex.so', + 'isPublic': true, 'id': 1, - 'note_id': 1, + 'noteId': 1, }, }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/presentation/http/router/noteSettings.ts b/src/presentation/http/router/noteSettings.ts index cc8ad017..03aaab8a 100644 --- a/src/presentation/http/router/noteSettings.ts +++ b/src/presentation/http/router/noteSettings.ts @@ -5,6 +5,7 @@ import { isEmpty } from '@infrastructure/utils/empty.js'; import useNoteResolver from '../middlewares/note/useNoteResolver.js'; import type NoteService from '@domain/service/note.js'; import type { NotePublicId } from '@domain/entities/note.js'; +import type { Team } from '@domain/entities/team.js'; /** * Interface for the note settings router. @@ -113,6 +114,26 @@ const NoteSettingsRouter: FastifyPluginCallback = (fa return reply.send(updatedNoteSettings); }); + /** + * TODO add policy for this route (check if user is collaborator) + */ + fastify.get<{ + Params: { + notePublicId: NotePublicId, + }, + Reply: Team, + }>('/:notePublicId/team', { + preHandler: [ + noteResolver, + ], + }, async (request, reply) => { + const noteId = request.note?.id as number; + + const team = await noteSettingsService.getTeamByNoteId(noteId); + + return reply.send(team); + }); + done(); }; diff --git a/src/repository/index.ts b/src/repository/index.ts index 4699ce73..2a57b841 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -13,6 +13,8 @@ import UserRepository from '@repository/user.repository.js'; import AIRepository from './ai.repository.js'; import OpenAIApi from './transport/openai-api/index.js'; import EditorToolsRepository from '@repository/editorTools.repository.js'; +import TeamRepository from '@repository/team.repository.js'; +import TeamStorage from '@repository/storage/team.storage.js'; /** * Interface for initiated repositories @@ -43,6 +45,11 @@ export interface Repositories { */ aiRepository: AIRepository editorToolsRepository: EditorToolsRepository, + + /** + * Team repository instance + */ + teamRepository: TeamRepository, } /** @@ -71,8 +78,10 @@ export async function init(orm: Orm): Promise { * Create storage instances */ const userStorage = new UserStorage(orm); - const noteSettingsStorage = new NoteSettingsStorage(orm); const noteStorage = new NoteStorage(orm); + const userSessionStorage = new UserSessionStorage(orm); + const noteSettingsStorage = new NoteSettingsStorage(orm); + const teamStorage = new TeamStorage(orm); /** * Create associations between note and note settings @@ -80,7 +89,12 @@ export async function init(orm: Orm): Promise { noteStorage.createAssociationWithNoteSettingsModel(noteSettingsStorage.model); noteSettingsStorage.createAssociationWithNoteModel(noteStorage.model); - const userSessionStorage = new UserSessionStorage(orm); + /** + * Create associations between note and team, user and team + */ + teamStorage.createAssociationWithNoteModel(noteStorage.model); + teamStorage.createAssociationWithUserModel(userStorage.model); + const editorToolsStorage = new EditorToolsStorage(orm); /** @@ -89,6 +103,7 @@ export async function init(orm: Orm): Promise { await userStorage.model.sync(); await noteStorage.model.sync(); await noteSettingsStorage.model.sync(); + await teamStorage.model.sync(); await userSessionStorage.model.sync(); await editorToolsStorage.model.sync(); @@ -107,6 +122,7 @@ export async function init(orm: Orm): Promise { const userRepository = new UserRepository(userStorage, googleApiTransport); const aiRepository = new AIRepository(openaiApiTransport); const editorToolsRepository = new EditorToolsRepository(editorToolsStorage); + const teamRepository = new TeamRepository(teamStorage); return { noteRepository, @@ -115,5 +131,6 @@ export async function init(orm: Orm): Promise { userRepository, aiRepository, editorToolsRepository, + teamRepository, }; } diff --git a/src/repository/storage/postgres/orm/sequelize/note.ts b/src/repository/storage/postgres/orm/sequelize/note.ts index 53e0a0c2..f33ec9b1 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -119,7 +119,7 @@ export default class NoteSequelizeStorage { * Create association with note settings, one-to-one */ this.model.hasOne(this.settingsModel, { - foreignKey: 'note_id', + foreignKey: 'noteId', as: 'noteSettings', }); } diff --git a/src/repository/storage/postgres/orm/sequelize/noteSettings.ts b/src/repository/storage/postgres/orm/sequelize/noteSettings.ts index a7b1b970..04c8af68 100644 --- a/src/repository/storage/postgres/orm/sequelize/noteSettings.ts +++ b/src/repository/storage/postgres/orm/sequelize/noteSettings.ts @@ -155,7 +155,7 @@ export default class NoteSettingsSequelizeStorage { * We can not create note settings without note */ this.model.belongsTo(model, { - foreignKey: 'note_id', + foreignKey: 'noteId', as: this.noteModel.tableName, }); } diff --git a/src/repository/storage/postgres/orm/sequelize/teams.ts b/src/repository/storage/postgres/orm/sequelize/teams.ts new file mode 100644 index 00000000..ee241656 --- /dev/null +++ b/src/repository/storage/postgres/orm/sequelize/teams.ts @@ -0,0 +1,202 @@ +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 '@repository/storage/postgres/orm/sequelize/note.js'; +import type { Team, TeamMemberCreationAttributes, TeamMember } from '@domain/entities/team.js'; +import { UserModel } from './user.js'; +import { MemberRole } from '@domain/entities/team.js'; +import type User from '@domain/entities/user.js'; +import type { NoteInternalId } from '@domain/entities/note.js'; + +/** + * Class representing a teams model in database + */ +export class TeamsModel extends Model, InferCreationAttributes> { + /** + * team member id + */ + public declare id: CreationOptional; + + /** + * Note ID + */ + public declare noteId: TeamMember['noteId']; + + /** + * Team member user id + */ + public declare userId: TeamMember['userId']; + + /** + * Team member role, show what user can do with note + */ + public declare role: MemberRole; +} + +/** + * Class representing a table storing note teams + */ +export default class TeamsSequelizeStorage { + /** + * Team model in database + */ + public model: typeof TeamsModel; + + /** + * Database instance + */ + private readonly database: Sequelize; + + /** + * Note model instance + */ + private noteModel: typeof NoteModel | null = null; + + /** + * User model instance + */ + private userModel: typeof UserModel | null = null; + + /** + * Teams table name + */ + private readonly tableName = 'note_teams'; + + /** + * Constructor for note storage + * + * @param ormInstance - ORM instance + */ + constructor({ connection }: Orm) { + this.database = connection; + + /** + * Initiate note note teams model + */ + this.model = TeamsModel.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', + }, + }, + role: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: MemberRole.read, + }, + }, { + tableName: this.tableName, + sequelize: this.database, + timestamps: false, + underscored: true, // use snake_case for fields in db + }); + } + + /** + * Creates association with note model to make joins + * + * @param model - initialized note model + */ + public createAssociationWithNoteModel(model: ModelStatic): void { + this.noteModel = model; + + /** + * Make one-to-one association with note model + * We can not create team relation without note + */ + this.model.belongsTo(model, { + foreignKey: 'noteId', + as: this.noteModel.tableName, + }); + } + + /** + * Creates association with user model to make joins + * + * @param model - initialized user model + */ + public createAssociationWithUserModel(model: ModelStatic): void { + this.userModel = model; + + /** + * Make one-to-one association with user model + * We can not create team relation without user + */ + this.model.belongsTo(model, { + foreignKey: 'userId', + as: this.userModel.tableName, + }); + } + + /** + * Create new team member + * + * @param data - team member data + */ + public async insert(data: TeamMemberCreationAttributes): Promise { + return await this.model.create(data); + } + + /** + * Get user role by user id and note id + * If user is not a member of note, return null + * + * @param userId - user id to check his role + * @param noteId - note id where user should have role + */ + public async getUserRoleByUserIdAndNoteId(userId: User['id'], noteId: NoteInternalId): Promise { + const res = await this.model.findOne({ + where: { + userId, + noteId, + }, + }); + + return res?.role ?? null; + } + + /** + * Get all team members by note id + * + * @param noteId - note id to get all team members + * @returns team relations + */ + public async getMembersByNoteId(noteId: NoteInternalId): Promise { + return await this.model.findAll({ + where: { + noteId, + }, + }); + } + + /** + * Remove team member by id + * + * @param id - team member id + */ + public async removeTeamMemberById(id: TeamMember['id']): Promise { + const affectedRows = await this.model.destroy({ + where: { + id, + }, + }); + + return affectedRows > 0; + } +} diff --git a/src/repository/storage/team.storage.ts b/src/repository/storage/team.storage.ts new file mode 100644 index 00000000..4e02b099 --- /dev/null +++ b/src/repository/storage/team.storage.ts @@ -0,0 +1,6 @@ +import TeamSequelizeStorage from './postgres/orm/sequelize/teams.js'; + +/** + * Current team storage + */ +export default TeamSequelizeStorage; \ No newline at end of file diff --git a/src/repository/team.repository.ts b/src/repository/team.repository.ts new file mode 100644 index 00000000..d486d0d9 --- /dev/null +++ b/src/repository/team.repository.ts @@ -0,0 +1,63 @@ +import type TeamStorage from '@repository/storage/team.storage.js'; +import type { MemberRole, Team, TeamMember, TeamMemberCreationAttributes } from '@domain/entities/team.js'; +import type { NoteInternalId } from '@domain/entities/note'; +import type User from '@domain/entities/user'; + +/** + * Repository allows accessing data from business-logic (domain) level + */ +export default class TeamRepository { + /** + * Team storage instance + */ + public storage: TeamStorage; + + /** + * Team repository constructor + * + * @param storage - storage for note + */ + constructor(storage: TeamStorage) { + this.storage = storage; + } + + /** + * Creates team member + * + * @param team - data for team creation + * @returns created team + */ + public async create(team: TeamMemberCreationAttributes): Promise { + return await this.storage.insert(team); + } + + /** + * Get user role in note by user id and note id + * If user is not a member of note, return null + * + * @param userId - user id to check his role + * @param noteId - note id where user should have role + */ + public async getUserRoleByUserIdAndNoteId(userId: User['id'], noteId: NoteInternalId): Promise { + return await this.storage.getUserRoleByUserIdAndNoteId(userId, noteId); + } + + /** + * Get all team members by note id + * + * @param noteId - note id to get all team members + * @returns team relations + */ + public async getByNoteId(noteId: NoteInternalId): Promise { + return await this.storage.getMembersByNoteId(noteId); + } + + /** + * Remove team member by id + * + * @param id - team member id + */ + public async removeMemberById(id: TeamMember['id']): Promise { + return await this.storage.removeTeamMemberById(id); + } +} diff --git a/src/tests/test-data/notes-settings.json b/src/tests/test-data/notes-settings.json index d95919ab..822b37c9 100644 --- a/src/tests/test-data/notes-settings.json +++ b/src/tests/test-data/notes-settings.json @@ -3,6 +3,6 @@ "id": 1, "note_id": 1, "custom_hostname": "codex.so", - "enabled": true + "is_public": true } ] diff --git a/src/tests/utils/insert-data.ts b/src/tests/utils/insert-data.ts index 3b463d31..cbb3c590 100644 --- a/src/tests/utils/insert-data.ts +++ b/src/tests/utils/insert-data.ts @@ -32,7 +32,7 @@ async function insertNotes(db: SequelizeOrm): Promise { */ async function insertNoteSettings(db: SequelizeOrm): Promise { for (const noteSetting of noteSettings) { - await db.connection.query(`INSERT INTO public.note_settings (id, "note_id", "custom_hostname", "enabled") VALUES (${noteSetting.id}, '${noteSetting.note_id}', '${noteSetting.custom_hostname}', ${noteSetting.enabled})`); + await db.connection.query(`INSERT INTO public.note_settings (id, "note_id", "custom_hostname", "is_public") VALUES (${noteSetting.id}, '${noteSetting.note_id}', '${noteSetting.custom_hostname}', ${noteSetting.is_public})`); } }