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: added ability to join note team #154

Merged
merged 26 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f83be88
added base invitation methods
slaveeks Dec 19, 2023
20a5a93
Merge branch 'main' of github.com:codex-team/notes.api into feat/join…
slaveeks Dec 19, 2023
ec9d9a3
feat: added ability to join to the team
slaveeks Jan 6, 2024
740fee9
Merge branch 'main' of github.com:codex-team/notes.api into feat/join…
slaveeks Jan 6, 2024
95377d8
feat: added tests for /join route
slaveeks Jan 7, 2024
d000740
fix: changed error response message
slaveeks Jan 7, 2024
5f47dab
feat: improved tests checks
slaveeks Jan 7, 2024
723726f
refactor: renamed create team membership method, changed messages in …
slaveeks Jan 9, 2024
513b75f
chore: added schemas for join POST route
slaveeks Jan 10, 2024
257a903
refactor: added comments for default value migration for note_teams t…
slaveeks Jan 10, 2024
66e1f66
refactor: changed todo doc
slaveeks Jan 10, 2024
e10b7e7
refactor: removed expectedStatus and expectedResponse vars from join …
slaveeks Jan 10, 2024
7eee5fe
refactor: use InvitationHash type in noteSettings storage and repository
slaveeks Jan 10, 2024
f7a661a
Apply suggestions from code review
slaveeks Jan 10, 2024
58dad93
fix: fix test
slaveeks Jan 10, 2024
4a3f865
fix: remove unused variables
slaveeks Jan 10, 2024
8d345d5
fix: changed error messages in tests
slaveeks Jan 10, 2024
579489d
fix: fixed join schema
slaveeks Jan 10, 2024
5cdf697
fix: turned back title to EditorTool
slaveeks Jan 10, 2024
2e58f38
chore: changed role type
slaveeks Jan 10, 2024
11f0410
feat: added migration to change role type
slaveeks Jan 10, 2024
772927d
Revert "chore: changed role type"
slaveeks Jan 10, 2024
03725aa
Revert "feat: added migration to change role type"
slaveeks Jan 10, 2024
d23d9e8
refactor: removed some variables and useless await from join test
slaveeks Jan 10, 2024
567027e
refacor: added todo for write permission
slaveeks Jan 10, 2024
fbd07ef
refacor: removed more useless awaits from tests
slaveeks Jan 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions migrations/tenant/[email protected]
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE SEQUENCE IF NOT EXISTS public.note_teams_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;

ALTER TABLE public.note_teams_id_seq OWNER TO codex;

ALTER SEQUENCE public.note_teams_id_seq OWNED BY public.note_teams.id;

-- Make identifier default value incrementing
ALTER TABLE ONLY public.note_teams ALTER COLUMN id SET DEFAULT nextval('public.note_teams_id_seq'::regclass);
neSpecc marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 5 additions & 5 deletions src/domain/entities/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import type User from './user.js';

export enum MemberRole {
/**
* Team member can read and write notes
* Team member can only read notes
*/
read = 'read',
read = 0,
slaveeks marked this conversation as resolved.
Show resolved Hide resolved

/**
* Team member can only read notes
* Team member can read and write notes
*/
write = 'write',
write = 1,
}

/**
Expand All @@ -36,7 +36,7 @@ export interface TeamMember {
/**
* Team member role, show what user can do with note
*/
role: MemberRole;
role: MemberRole;
}

export type Team = TeamMember[];
Expand Down
45 changes: 40 additions & 5 deletions src/domain/service/noteSettings.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { NoteInternalId } from '@domain/entities/note.js';
import type { InvitationHash } from '@domain/entities/noteSettings.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 { Team, TeamMember, TeamMemberCreationAttributes } from '@domain/entities/team.js';
import { MemberRole } from '@domain/entities/team.js';
import type User from '@domain/entities/user.js';
import { createInvitationHash } from '@infrastructure/utils/invitationHash.js';

Expand All @@ -20,14 +22,47 @@ export default class NoteSettingsService {
/**
* Note Settings service constructor
*
* @param noteSettingsrepository - note settings repository
* @param noteSettingsRepository - note settings repository
* @param teamRepository - team repository
*/
constructor(noteSettingsrepository: NoteSettingsRepository, teamRepository: TeamRepository) {
this.noteSettingsRepository = noteSettingsrepository;
constructor(noteSettingsRepository: NoteSettingsRepository, teamRepository: TeamRepository) {
this.noteSettingsRepository = noteSettingsRepository;
this.teamRepository = teamRepository;
}

/**
* Add user to the team by invitation hash
*
* @param invitationHash - hash for joining to the team
* @param userId - user to add
*/
public async addUserToTeamByInvitationHash(invitationHash: InvitationHash, userId: User['id']): Promise<TeamMember | null> {
const defaultUserRole = MemberRole.read;
const noteSettings = await this.noteSettingsRepository.getNoteSettingsByInvitationHash(invitationHash);

/**
* Check if invitation hash is valid
*/
if (noteSettings === null) {
throw new Error(`Wrong invitation`);
}

/**
* Check if user not already in team
*/
const isUserTeamMember = await this.teamRepository.isUserInTeam(userId, noteSettings.noteId);

if (isUserTeamMember) {
throw new Error(`User already in team`);
}

return await this.teamRepository.createTeamMembership({
noteId: noteSettings.noteId,
userId,
role: defaultUserRole,
});
}

/**
* Returns settings for a note with passed id
*
Expand Down Expand Up @@ -102,6 +137,6 @@ export default class NoteSettingsService {
* @returns created team member
*/
public async createTeamMember(team: TeamMemberCreationAttributes): Promise<TeamMember> {
return await this.teamRepository.create(team);
return await this.teamRepository.createTeamMembership(team);
}
}
9 changes: 9 additions & 0 deletions src/presentation/http/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type { RequestParams, Response } from '@presentation/api.interface.js';
import NoteSettingsRouter from './router/noteSettings.js';
import NoteListRouter from '@presentation/http/router/noteList.js';
import { EditorToolSchema } from './schema/EditorTool.js';
import JoinRouter from '@presentation/http/router/join.js';
import { JoinSchemaParams, JoinSchemaResponse } from './schema/Join.js';


const appServerLogger = getLogger('appServer');
Expand Down Expand Up @@ -197,6 +199,11 @@ export default class HttpApi implements Api {
noteListService: domainServices.noteListService,
});

await this.server?.register(JoinRouter, {
prefix: '/join',
noteSettings: domainServices.noteSettingsService,
});

await this.server?.register(NoteSettingsRouter, {
prefix: '/note-settings',
noteSettingsService: domainServices.noteSettingsService,
Expand Down Expand Up @@ -268,6 +275,8 @@ export default class HttpApi implements Api {
this.server?.addSchema(UserSchema);
this.server?.addSchema(NoteSchema);
this.server?.addSchema(EditorToolSchema);
this.server?.addSchema(JoinSchemaParams);
this.server?.addSchema(JoinSchemaResponse);
}

/**
Expand Down
69 changes: 69 additions & 0 deletions src/presentation/http/router/join.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, test, expect } from 'vitest';

describe('Join API', () => {
describe('POST /join/:hash', () => {
test('Returns 406 when user is already in the team', async () => {
const hash = 'Hzh2hy4igf';
const userId = 2;
const accessToken = global.auth(userId);

const response = await global.api?.fakeRequest({
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
},
url: `/join/${hash}`,
});

expect(response?.statusCode).toBe(406);

expect(await response?.json()).toStrictEqual({
message: 'User already in team',
});
});
test('Returns 406 when invitation hash is not valid', async () => {
const hash = 'Jih23y4igf';
const userId = 4;
const accessToken = global.auth(userId);

const response = await global.api?.fakeRequest({
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
},
url: `/join/${hash}`,
});

expect(response?.statusCode).toBe(406);

expect(await response?.json()).toStrictEqual({
message: `Wrong invitation`,
e11sy marked this conversation as resolved.
Show resolved Hide resolved
});
});

test('Returns 200 when user is added to the team', async () => {
slaveeks marked this conversation as resolved.
Show resolved Hide resolved
const hash = 'Hzh2hy4igf';
const userId = 1;
const accessToken = global.auth(userId);

const response = await global.api?.fakeRequest({
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
},
url: `/join/${hash}`,
});

expect(response?.statusCode).toBe(200);

expect(response?.json()).toStrictEqual({
result: {
id: 2,
userId,
noteId: 1,
role: 0,
},
});
});
});
});
75 changes: 75 additions & 0 deletions src/presentation/http/router/join.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { FastifyPluginCallback } from 'fastify';
import type NoteSettingsService from '@domain/service/noteSettings.js';
import type { TeamMember } from '@domain/entities/team.js';

/**
* Represents AI router options
*/
interface JoinRouterOptions {
/**
* Note settings service instance
*/
noteSettings: NoteSettingsService
}

/**
* Join Router plugin
*
* @todo use different replies for different errors in post route
* @todo add check for write permission in route
* @param fastify - fastify instance
* @param opts - router options
* @param done - done callback
*/
const JoinRouter: FastifyPluginCallback<JoinRouterOptions> = (fastify, opts, done) => {
const noteSettingsService = opts.noteSettings;

fastify.post<{
Params: {
hash: string
}
}>('/:hash', {
schema: {
params: {
hash: {
$ref: 'JoinSchemaParams#/properties/hash',
},
},
response: {
'2xx': {
description: 'Team member',
content: {
'application/json': {
schema: {
$ref: 'JoinSchemaResponse#/properties',
},
},
},
},
},
},
config: {
policy: [
'authRequired',
],
neSpecc marked this conversation as resolved.
Show resolved Hide resolved
},
neSpecc marked this conversation as resolved.
Show resolved Hide resolved
}, async (request, reply) => {
const { hash } = request.params;
const { userId } = request;
let result: TeamMember | null = null;

try {
result = await noteSettingsService.addUserToTeamByInvitationHash(hash, userId as number);
} catch (error: unknown) {
const causedError = error as Error;

return reply.notAcceptable(causedError.message);
}

return reply.send({ result });
});

done();
};

export default JoinRouter;
39 changes: 39 additions & 0 deletions src/presentation/http/schema/Join.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export const JoinSchemaParams = {
$id: 'JoinSchemaParams',
type: 'object',
properties: {
hash: {
type: 'string',
pattern: '[a-zA-Z0-9-_]+',
maxLength: 10,
minLength: 10,
},
},
};

export const JoinSchemaResponse = {
$id: 'JoinSchemaResponse',
type: 'object',
properties: {
result: {
type: 'object',
properties: {
id: {
type: 'number',
},
noteId: {
type: 'string',
pattern: '[a-zA-Z0-9-_]+',
maxLength: 10,
minLength: 10,
},
userId: {
type: 'number',
},
role: {
type: 'number',
},
},
},
},
};
12 changes: 11 additions & 1 deletion src/repository/noteSettings.repository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { NoteInternalId } from '@domain/entities/note.js';
import type NoteSettings from '@domain/entities/noteSettings.js';
import type NoteSettingsStorage from '@repository/storage/noteSettings.storage.js';
import type { NoteSettingsCreationAttributes } from '@domain/entities/noteSettings.js';
import type { InvitationHash, NoteSettingsCreationAttributes } from '@domain/entities/noteSettings.js';

/**
* Repository allows accessing data from business-logic (domain) level
Expand Down Expand Up @@ -31,6 +31,16 @@ export default class NoteSettingsRepository {
return await this.storage.getNoteSettingsById(id);
}

/**
* Get note settings by invitation hash
*
* @param invitationHash - hash for inviting to the note team
* @returns { Promise<NoteSettings | null> } - found note settings
*/
public async getNoteSettingsByInvitationHash(invitationHash: InvitationHash): Promise<NoteSettings | null> {
return await this.storage.getNoteSettingsByInvitationHash(invitationHash);
}

/**
* Get note settings by note id
*
Expand Down
16 changes: 15 additions & 1 deletion src/repository/storage/postgres/orm/sequelize/noteSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 NoteSettings from '@domain/entities/noteSettings.js';
import type { NoteSettingsCreationAttributes } from '@domain/entities/noteSettings.js';
import type { InvitationHash, NoteSettingsCreationAttributes } from '@domain/entities/noteSettings.js';

/**
* Class representing a notes settings model in database
Expand Down Expand Up @@ -127,6 +127,20 @@ export default class NoteSettingsSequelizeStorage {
return noteSettings;
}

/**
* Get note settings by invitation hash
*
* @param invitationHash - hash for inviting to the note team
* @returns { Promise<NoteSettings | null> } - found note settings
*/
public async getNoteSettingsByInvitationHash(invitationHash: InvitationHash): Promise<NoteSettings | null> {
return await this.model.findOne({
where: {
invitationHash,
},
});
}

/**
* Get note settings
*
Expand Down
Loading
Loading