From bb27dfe333572de22174c3b1a33822d1ec456f1a Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Fri, 15 Oct 2021 16:50:14 +0300 Subject: [PATCH 1/4] feat: invite service --- .../20211014105721_create_invites_table.js | 15 + api/src/app.js | 7 + api/src/models/Invite.js | 23 + .../email/controllers/updatePassword.js | 2 +- .../invites/controllers/createInvite.js | 106 +++++ .../services/invites/controllers/getInvite.js | 34 ++ .../invites/controllers/resourceInvites.js | 27 ++ .../invites/controllers/revokeInvite.js | 47 ++ api/src/services/invites/index.js | 10 + api/src/services/invites/routes.js | 11 + .../lessons/controllers/enrollLesson.js | 51 +- .../services/user/controllers/refreshToken.js | 4 +- api/src/services/user/controllers/signIn.js | 2 +- api/src/services/user/controllers/signUp.js | 2 +- api/src/services/user/utils.js | 3 +- api/test/integration/invites.spec.js | 447 ++++++++++++++++++ 16 files changed, 781 insertions(+), 10 deletions(-) create mode 100644 api/migrations/20211014105721_create_invites_table.js create mode 100644 api/src/models/Invite.js create mode 100644 api/src/services/invites/controllers/createInvite.js create mode 100644 api/src/services/invites/controllers/getInvite.js create mode 100644 api/src/services/invites/controllers/resourceInvites.js create mode 100644 api/src/services/invites/controllers/revokeInvite.js create mode 100644 api/src/services/invites/index.js create mode 100644 api/src/services/invites/routes.js create mode 100644 api/test/integration/invites.spec.js diff --git a/api/migrations/20211014105721_create_invites_table.js b/api/migrations/20211014105721_create_invites_table.js new file mode 100644 index 00000000..4787e486 --- /dev/null +++ b/api/migrations/20211014105721_create_invites_table.js @@ -0,0 +1,15 @@ +export const up = (knex) => + knex.schema.createTable('invites', (table) => { + table + .uuid('id') + .notNullable() + .defaultTo(knex.raw('gen_random_uuid()')) + .primary(); + table.integer('resource_id').notNullable(); + table.enum('resource_type', ['lesson', 'course']).notNullable(); + table.enum('status', ['revoked', 'pending', 'success']).notNullable(); + table.string('email'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + }); + +export const down = (knex) => knex.schema.dropTable('invites'); diff --git a/api/src/app.js b/api/src/app.js index 3b7f518d..05c5af9b 100644 --- a/api/src/app.js +++ b/api/src/app.js @@ -17,6 +17,7 @@ import Keyword from './models/Keyword'; import ResourceKeyword from './models/ResourceKeyword'; import File from './models/File'; import ResourceFile from './models/ResourceFile'; +import Invite from './models/Invite'; import userService from './services/user'; import lessonsService from './services/lessons'; @@ -27,6 +28,7 @@ import coursesService from './services/courses'; import emailService from './services/email'; import keywordsService from './services/keywords'; import filesService from './services/files'; +import invitesService from './services/invites'; import errorsAndValidation from './validation'; import i18n from './i18n'; @@ -71,6 +73,7 @@ export default (options = {}) => { ResourceKeyword, File, ResourceFile, + Invite, ], }); @@ -110,5 +113,9 @@ export default (options = {}) => { prefix: '/api/v1/email', }); + app.register(invitesService, { + prefix: '/api/v1/invites', + }); + return app; }; diff --git a/api/src/models/Invite.js b/api/src/models/Invite.js new file mode 100644 index 00000000..f67f7f8e --- /dev/null +++ b/api/src/models/Invite.js @@ -0,0 +1,23 @@ +import BaseModel from './BaseModel'; + +class Invite extends BaseModel { + static get tableName() { + return 'invites'; + } + + static get jsonSchema() { + return { + type: 'object', + properties: { + id: { type: 'string' }, + resourceId: { type: 'integer' }, + resourceType: { type: 'string' }, + status: { type: 'string' }, + email: { type: 'string' }, + createdAt: { type: 'string' }, + }, + }; + } +} + +export default Invite; diff --git a/api/src/services/email/controllers/updatePassword.js b/api/src/services/email/controllers/updatePassword.js index ba17ba25..0914b72b 100644 --- a/api/src/services/email/controllers/updatePassword.js +++ b/api/src/services/email/controllers/updatePassword.js @@ -43,7 +43,7 @@ async function handler({ params: { id: uuid }, body: { password } }) { userId, }); - const accessToken = createAccessToken(this, userId); + const accessToken = createAccessToken(this, userId, email); const refreshToken = createRefreshToken(this, userId); await Redis.invalidateLink({ email, uuid }); diff --git a/api/src/services/invites/controllers/createInvite.js b/api/src/services/invites/controllers/createInvite.js new file mode 100644 index 00000000..628cc3af --- /dev/null +++ b/api/src/services/invites/controllers/createInvite.js @@ -0,0 +1,106 @@ +const options = { + schema: { + body: { + type: 'object', + properties: { + resourceId: { type: 'integer' }, + resourceType: { type: 'string' }, + emails: { + type: 'array', + default: [], + items: { + type: 'string', + }, + }, + }, + required: ['resourceId', 'resourceType', 'emails'], + }, + }, + async onRequest(req) { + await this.auth({ req }); + }, + async preHandler({ user, body }) { + const { + models: { Lesson, Course }, + config: { + globals: { roles, resources }, + }, + } = this; + + await this.access({ + userId: user.id, + resourceId: body.resourceId, + resourceType: body.resourceType, + roleId: roles.MAINTAINER.id, + }); + + const isLesson = body.resourceType === resources.LESSON.name; + await (isLesson ? Lesson : Course) + .query() + .first() + .where({ + id: body.resourceId, + status: 'Private', + }) + .throwIfNotFound(); + }, +}; + +async function handler({ body }) { + const { + models: { Invite }, + } = this; + // trx + let data; + + if (body.emails.length) { + await Invite.query() + .patch({ status: 'revoked' }) + .where({ + resource_id: body.resourceId, + resource_type: body.resourceType, + status: 'pending', + }) + .whereIn('email', body.emails) + .returning('*'); + + data = body.emails.map((email) => ({ + resource_id: body.resourceId, + resource_type: body.resourceType, + status: 'pending', + email, + })); + } else { + await Invite.query() + .patch({ status: 'revoked' }) + .where({ + resource_id: body.resourceId, + resource_type: body.resourceType, + status: 'pending', + }) + .whereNull('email') + .returning('*'); + + data = { + resource_id: body.resourceId, + resource_type: body.resourceType, + status: 'pending', + }; + } + + const invites = await Invite.query() + .skipUndefined() + .insert(data) + .returning('*'); + + // send email here + + return { + invites: invites.map?.((invite) => ({ + email: invite.email, + invite: invite.id, + })) || [{ email: invites.email, invite: invites.id }], + }; +} + +export default { options, handler }; diff --git a/api/src/services/invites/controllers/getInvite.js b/api/src/services/invites/controllers/getInvite.js new file mode 100644 index 00000000..d3d4f482 --- /dev/null +++ b/api/src/services/invites/controllers/getInvite.js @@ -0,0 +1,34 @@ +import { NotFoundError } from '../../../validation/errors'; + +const options = { + schema: { + params: { + type: 'object', + properties: { + inviteId: { type: 'string' }, + }, + required: ['inviteId'], + }, + }, +}; + +async function handler({ params }) { + const { + models: { Invite, User }, + } = this; + + const invite = await Invite.query() + .findById(params.inviteId) + .throwIfNotFound({ errors: new NotFoundError('err') }); + const user = await User.query().first().where({ email: invite.email }); + + return { + invite: invite.id, + email: invite.email, + resourceId: invite.resourceId, + resourceType: invite.resourceType, + isRegistered: !!user, + }; +} + +export default { options, handler }; diff --git a/api/src/services/invites/controllers/resourceInvites.js b/api/src/services/invites/controllers/resourceInvites.js new file mode 100644 index 00000000..674cc3e0 --- /dev/null +++ b/api/src/services/invites/controllers/resourceInvites.js @@ -0,0 +1,27 @@ +const options = { + schema: { + params: { + type: 'object', + properties: { + resourceId: { type: 'integer' }, + resourceType: { type: 'string' }, + }, + required: ['resourceId', 'resourceType'], + }, + }, +}; + +async function handler({ params }) { + const { + models: { Invite }, + } = this; + + const invites = await Invite.query().where({ + resource_id: params.resourceId, + resource_type: params.resourceType, + }); + + return { invites }; +} + +export default { options, handler }; diff --git a/api/src/services/invites/controllers/revokeInvite.js b/api/src/services/invites/controllers/revokeInvite.js new file mode 100644 index 00000000..144a7ae9 --- /dev/null +++ b/api/src/services/invites/controllers/revokeInvite.js @@ -0,0 +1,47 @@ +import { BadRequestError } from '../../../validation/errors'; + +const options = { + schema: { + params: { + type: 'object', + properties: { + inviteId: { type: 'string' }, + }, + required: ['inviteId'], + }, + }, + async onRequest(req) { + await this.auth({ req }); + }, + async preHandler({ user, params }) { + const { + models: { Invite }, + config: { + globals: { roles }, + }, + } = this; + + const invite = await Invite.query() + .findById(params.inviteId) + .throwIfNotFound({ error: new BadRequestError('invalid invite') }); + + await this.access({ + userId: user.id, + resourceId: invite.resourceId, + resourceType: invite.resourceType, + roleId: roles.MAINTAINER.id, + }); + }, +}; + +async function handler({ params }) { + const { + models: { Invite }, + } = this; + + await Invite.query().findById(params.inviteId).patch({ status: 'revoked' }); + + return { message: 'success' }; +} + +export default { options, handler }; diff --git a/api/src/services/invites/index.js b/api/src/services/invites/index.js new file mode 100644 index 00000000..b8feadea --- /dev/null +++ b/api/src/services/invites/index.js @@ -0,0 +1,10 @@ +import fp from 'fastify-plugin'; + +import { router } from './routes'; + +const invitesService = (instance, opts, done) => { + instance.register(router, opts); + done(); +}; + +export default fp(invitesService); diff --git a/api/src/services/invites/routes.js b/api/src/services/invites/routes.js new file mode 100644 index 00000000..5c31e3af --- /dev/null +++ b/api/src/services/invites/routes.js @@ -0,0 +1,11 @@ +import resourceInvites from './controllers/resourceInvites'; +import createInvite from './controllers/createInvite'; +import revokeInvite from './controllers/revokeInvite'; +import getInvite from './controllers/getInvite'; + +export async function router(instance) { + instance.get('/', resourceInvites.options, resourceInvites.handler); + instance.post('/', createInvite.options, createInvite.handler); + instance.delete('/:inviteId', revokeInvite.options, revokeInvite.handler); + instance.get('/:inviteId', getInvite.options, getInvite.handler); +} diff --git a/api/src/services/lessons/controllers/enrollLesson.js b/api/src/services/lessons/controllers/enrollLesson.js index 7f002384..9f03c79c 100644 --- a/api/src/services/lessons/controllers/enrollLesson.js +++ b/api/src/services/lessons/controllers/enrollLesson.js @@ -1,3 +1,5 @@ +import { BadRequestError } from '../../../validation/errors'; + const options = { schema: { params: { $ref: 'paramsLessonId#' }, @@ -16,24 +18,65 @@ const options = { async onRequest(req) { await this.auth({ req }); }, + async preHandler(req) { + const { + models: { Invite }, + config: { + lessonService: { lessonServiceErrors: errors }, + }, + } = this; + + const { body, params, user } = req; + + if (body?.invite) { + const invite = await Invite.query() + .first() + .where({ + id: body.invite, + resource_id: params.lessonId, + resource_type: 'lesson', + status: 'pending', + }) + .throwIfNotFound({ + error: new BadRequestError(errors.LESSON_ERR_FAIL_ENROLL), + }); + + if (invite.email) { + req.params.isInvite = true; + if (invite.email !== user.email) { + throw new BadRequestError(errors.LESSON_ERR_FAIL_ENROLL); + } + } + } + }, }; -async function handler({ user: { id: userId }, params: { lessonId } }) { +async function handler({ + user: { id: userId }, + params: { lessonId, isInvite }, + body, +}) { const { config: { lessonService: { lessonServiceMessages: messages }, globals: { resources }, }, - models: { UserRole }, + models: { UserRole, Invite }, } = this; - + // trx await UserRole.enrollToResource({ userId, resourceId: lessonId, resourceType: resources.LESSON.name, - resourceStatuses: resources.LESSON.enrollStatuses, + resourceStatuses: body?.invite + ? [...resources.LESSON.enrollStatuses, 'Private'] + : resources.LESSON.enrollStatuses, }); + if (isInvite) { + await Invite.query().findById(body.invite).patch({ status: 'success' }); + } + return { message: messages.LESSON_MSG_SUCCESS_ENROLL }; } diff --git a/api/src/services/user/controllers/refreshToken.js b/api/src/services/user/controllers/refreshToken.js index af4524b4..9e5b803f 100644 --- a/api/src/services/user/controllers/refreshToken.js +++ b/api/src/services/user/controllers/refreshToken.js @@ -34,7 +34,7 @@ async function handler(req) { throw new AuthorizationError(errors.USER_ERR_TOKEN_EXPIRED); } - const { id, updatedAt } = await User.getUser({ userId: decoded.id }); + const { id, email, updatedAt } = await User.getUser({ userId: decoded.id }); const lastUpdateUserTime = Math.floor(new Date(updatedAt).getTime() / 1000); const tokenCreatedTime = decoded.iat; @@ -42,7 +42,7 @@ async function handler(req) { throw new AuthorizationError(errors.USER_ERR_TOKEN_EXPIRED); } - const newAccessToken = createAccessToken(this, id); + const newAccessToken = createAccessToken(this, id, email); const newRefreshToken = createRefreshToken(this, id); return { diff --git a/api/src/services/user/controllers/signIn.js b/api/src/services/user/controllers/signIn.js index 6aa5acb3..73f2d132 100644 --- a/api/src/services/user/controllers/signIn.js +++ b/api/src/services/user/controllers/signIn.js @@ -37,7 +37,7 @@ async function handler({ body: { email, password } }) { throw new AuthorizationError(errors.USER_ERR_UNAUTHORIZED); } - const accessToken = createAccessToken(this, id); + const accessToken = createAccessToken(this, id, email); const refreshToken = createRefreshToken(this, id); return { diff --git a/api/src/services/user/controllers/signUp.js b/api/src/services/user/controllers/signUp.js index 5678815d..2da26de8 100644 --- a/api/src/services/user/controllers/signUp.js +++ b/api/src/services/user/controllers/signUp.js @@ -37,7 +37,7 @@ async function handler({ body, headers }) { userData: { ...body, email: body.email.toLowerCase(), password: hash }, }); - const accessToken = createAccessToken(this, id); + const accessToken = createAccessToken(this, id, email); const refreshToken = createRefreshToken(this, id); const link = await Redis.generateConfirmationLink({ host, email }); diff --git a/api/src/services/user/utils.js b/api/src/services/user/utils.js index e236f106..6cc36573 100644 --- a/api/src/services/user/utils.js +++ b/api/src/services/user/utils.js @@ -1,10 +1,11 @@ import { BadRequestError } from '../../validation/errors'; -export const createAccessToken = (instance, userId) => +export const createAccessToken = (instance, userId, email) => instance.jwt.sign( { access: true, id: userId, + email, }, { expiresIn: instance.config.globals.jwt.ACCESS_JWT_EXPIRES_IN }, ); diff --git a/api/test/integration/invites.spec.js b/api/test/integration/invites.spec.js new file mode 100644 index 00000000..d54a5315 --- /dev/null +++ b/api/test/integration/invites.spec.js @@ -0,0 +1,447 @@ +import build from '../../src/app'; + +import { + teacherMike, + studentJohn, + defaultPassword, +} from '../../seeds/testData/users'; +import { authorizeUser, createLesson } from './utils'; +import { lessonServiceErrors, lessonServiceMessages } from '../../src/config'; + +describe('Invites service test', () => { + const testContext = { + app: null, + teachersToken: null, + studentsToken: null, + teachersRequest: () => {}, + studentsRequest: () => {}, + }; + + const teachersCredentials = { + email: teacherMike.email, + password: defaultPassword, + }; + + const studentsCredentials = { + email: studentJohn.email, + password: defaultPassword, + }; + + beforeAll(async () => { + testContext.app = build(); + + await authorizeUser({ + credentials: teachersCredentials, + app: testContext.app, + setToken: (token) => { + testContext.teachersToken = token; + }, + }); + await authorizeUser({ + credentials: studentsCredentials, + app: testContext.app, + setToken: (token) => { + testContext.studentsToken = token; + }, + }); + + testContext.teachersRequest = ({ url, method = 'POST', body }) => { + return testContext.app.inject({ + method, + url: `/api/v1/${url}`, + headers: { + authorization: `Bearer ${testContext.teachersToken}`, + }, + body, + }); + }; + testContext.studentsRequest = ({ url, method = 'POST', body }) => { + return testContext.app.inject({ + method, + url: `/api/v1/${url}`, + headers: { + authorization: `Bearer ${testContext.studentsToken}`, + }, + body, + }); + }; + }); + + afterAll(async () => { + await testContext.app.close(); + }); + + describe('Private lesson should not be enrollable without an invite', () => { + let lessonToEnroll; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + }); + + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); + }); + }); + + describe('Creation of a general link', () => { + let lessonToInvite; + + beforeAll(async () => { + lessonToInvite = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + }); + + it('should successfully create a link', async () => { + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToInvite.lesson.id, + resourceType: 'lesson', + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('invites'); + expect(payload.invites[0]).toMatchObject({ + email: null, + invite: expect.any(String), + }); + }); + }); + + describe('Enroll with a general link', () => { + let lessonToEnroll; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; + }); + + it('should be enrollable', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, + ); + }); + }); + + describe('Enroll with an invalid invite', () => { + let lessonToEnroll; + let lessonToInvite; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + lessonToInvite = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToInvite.lesson.id, + resourceType: 'lesson', + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; + }); + + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); + }); + }); + + describe('Creation of an invite with an email', () => { + let lessonToInvite; + + beforeAll(async () => { + lessonToInvite = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + }); + + it('should successfully create a link', async () => { + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToInvite.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('invites'); + expect(payload.invites[0]).toMatchObject({ + email: studentJohn.email, + invite: expect.any(String), + }); + }); + }); + + describe('Enroll with an invite', () => { + let lessonToEnroll; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; + }); + + it('should be enrollable', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, + ); + }); + }); + + describe('Enroll with an invalid invite', () => { + let lessonToEnroll; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: ['student@test.io'], + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; + }); + + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); + }); + }); + + describe('Revoking an invite by creating a new one', () => { + let lessonToEnroll; + let revokedInvite; + let workingInvite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const revokedInviteResponse = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); + + const { invites: revokedInvitesPayload } = JSON.parse( + revokedInviteResponse.payload, + ); + revokedInvite = revokedInvitesPayload[0].invite; + + const workingInviteResponse = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); + + const { invites: workingInvitePayload } = JSON.parse( + workingInviteResponse.payload, + ); + workingInvite = workingInvitePayload[0].invite; + }); + + it('should return an error if enroll with a revoked invite', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite: revokedInvite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); + }); + + it('should successfully enroll with a working invite', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite: workingInvite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, + ); + }); + }); +}); From b409d312d4b2b7dcc6673076f38fc3b94ba6f4d4 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Sun, 24 Oct 2021 19:23:27 +0300 Subject: [PATCH 2/4] feat: improve invites service --- api/src/config/globals.js | 6 + api/src/config/index.js | 3 + api/src/config/invitesService.js | 7 + api/src/i18n/locales/en/email.json | 4 + api/src/i18n/locales/ru/email.json | 4 + api/src/models/Invite.js | 71 ++ api/src/models/UserRole.js | 3 +- .../courses/controllers/enrollCourse.js | 39 +- api/src/services/email/models/Email.js | 8 + .../invites/controllers/createInvite.js | 101 +- .../services/invites/controllers/getInvite.js | 8 +- .../invites/controllers/resourceInvites.js | 6 +- .../invites/controllers/revokeInvite.js | 13 +- api/src/services/invites/hooks/index.js | 3 + .../services/invites/hooks/processInvite.js | 27 + api/src/services/invites/index.js | 2 + .../lessons/controllers/enrollLesson.js | 60 +- api/test/integration/invites.spec.js | 943 ++++++++++++------ 18 files changed, 921 insertions(+), 387 deletions(-) create mode 100644 api/src/config/invitesService.js create mode 100644 api/src/services/invites/hooks/index.js create mode 100644 api/src/services/invites/hooks/processInvite.js diff --git a/api/src/config/globals.js b/api/src/config/globals.js index 5f192c41..e2f7eed2 100644 --- a/api/src/config/globals.js +++ b/api/src/config/globals.js @@ -130,3 +130,9 @@ export const globalErrors = { export const S3_URL = 'http://s3:9000/storage/'; export const FILE_SIZE_LIMIT = 1_000_000; // 1 MB + +export const invitesStatuses = { + REVOKED: 'revoked', + PENDING: 'pending', + SUCCESS: 'success', +}; diff --git a/api/src/config/index.js b/api/src/config/index.js index 671c83b5..e7a2677f 100644 --- a/api/src/config/index.js +++ b/api/src/config/index.js @@ -4,6 +4,7 @@ import * as lessonService from './lessonService'; import * as emailService from './emailService'; import * as courseService from './courseService'; import * as fileService from './fileService'; +import * as invitesService from './invitesService'; export default { globals, @@ -12,6 +13,7 @@ export default { lessonService, courseService, fileService, + invitesService, }; export * from './globals'; @@ -20,3 +22,4 @@ export * from './lessonService'; export * from './courseService'; export * from './fileService'; export * from './emailService'; +export * from './invitesService'; diff --git a/api/src/config/invitesService.js b/api/src/config/invitesService.js new file mode 100644 index 00000000..5cc99187 --- /dev/null +++ b/api/src/config/invitesService.js @@ -0,0 +1,7 @@ +export const invitesServiceErrors = { + INVITE_ERR_NOT_FOUND: 'errors.invite_not_found', +}; + +export const invitesServiceMessages = { + INVITE_MSG_REVOKE_SUCCESS: 'messages.invite_revoked', +}; diff --git a/api/src/i18n/locales/en/email.json b/api/src/i18n/locales/en/email.json index bee4d58a..9442d0f4 100644 --- a/api/src/i18n/locales/en/email.json +++ b/api/src/i18n/locales/en/email.json @@ -10,5 +10,9 @@ "email_confirmation": { "subject": "Email confirmation", "html": "Confirm email link {{link}}" + }, + "invite": { + "subject": "You were invited to start learning at StudyBites", + "html": "Follow the link to start: {{link}}" } } diff --git a/api/src/i18n/locales/ru/email.json b/api/src/i18n/locales/ru/email.json index 84fcb372..519013f8 100644 --- a/api/src/i18n/locales/ru/email.json +++ b/api/src/i18n/locales/ru/email.json @@ -10,5 +10,9 @@ "email_confirmation": { "subject": "Email подтверждение", "html": "Подтвердите ссылку на электронную почту {{link}}" + }, + "invite": { + "subject": "Вас пригласили начать обучение на StudyBites", + "html": "Пройдите по ссылке, чтобы начать: {{link}}" } } diff --git a/api/src/models/Invite.js b/api/src/models/Invite.js index f67f7f8e..a041a611 100644 --- a/api/src/models/Invite.js +++ b/api/src/models/Invite.js @@ -1,4 +1,11 @@ import BaseModel from './BaseModel'; +import { BadRequestError, NotFoundError } from '../validation/errors'; +import { + invitesServiceErrors, + invitesStatuses, + lessonServiceErrors as errors, + resources, +} from '../config'; class Invite extends BaseModel { static get tableName() { @@ -18,6 +25,70 @@ class Invite extends BaseModel { }, }; } + + static checkIfPendingInvite({ inviteId, resourceId, resourceType }) { + return this.query() + .first() + .where({ + id: inviteId, + resource_id: resourceId, + resource_type: resourceType, + status: invitesStatuses.PENDING, + }) + .throwIfNotFound({ + error: new BadRequestError(errors.LESSON_ERR_FAIL_ENROLL), + }); + } + + static setInviteSuccess({ trx, inviteId }) { + return this.query(trx) + .findById(inviteId) + .patch({ status: invitesStatuses.SUCCESS }); + } + + static revokeInvites({ trx, resourceId, resourceType, emails }) { + const query = this.query(trx) + .patch({ status: invitesStatuses.REVOKED }) + .where({ + resource_id: resourceId, + resource_type: resourceType, + status: invitesStatuses.PENDING, + }) + .returning('*'); + + if (emails.length) { + query.whereIn('email', emails); + } else { + query.whereNull('email'); + } + + return query; + } + + static createInvites({ trx, data }) { + return this.query(trx).skipUndefined().insert(data).returning('*'); + } + + static getInviteById({ inviteId }) { + return this.query() + .findById(inviteId) + .throwIfNotFound({ + errors: new NotFoundError(invitesServiceErrors.INVITE_ERR_NOT_FOUND), + }); + } + + static getResourceInvites({ resourceId, resourceType }) { + return this.query().where({ + resource_id: resourceId, + resource_type: resourceType, + }); + } + + static revokeOneInvite({ inviteId }) { + return this.query() + .findById(inviteId) + .patch({ status: invitesStatuses.REVOKED }); + } } export default Invite; diff --git a/api/src/models/UserRole.js b/api/src/models/UserRole.js index 1cfad636..473e47a5 100644 --- a/api/src/models/UserRole.js +++ b/api/src/models/UserRole.js @@ -251,12 +251,13 @@ class UserRole extends BaseModel { } static async enrollToResource({ + trx, userId, resourceId, resourceType, resourceStatuses, }) { - await this.query() + await this.query(trx) .findById(resourceId) .from(resourceType === resources.COURSE.name ? 'courses' : 'lessons') .whereIn('status', resourceStatuses) diff --git a/api/src/services/courses/controllers/enrollCourse.js b/api/src/services/courses/controllers/enrollCourse.js index 59c8eaf2..0c3bef49 100644 --- a/api/src/services/courses/controllers/enrollCourse.js +++ b/api/src/services/courses/controllers/enrollCourse.js @@ -16,22 +16,47 @@ const options = { async onRequest(req) { await this.auth({ req }); }, + async preHandler(req) { + const { + config: { + globals: { resources }, + }, + } = this; + await this.processInvite({ + req, + resourceType: resources.COURSE.name, + resourceId: req.params.courseId, + }); + }, }; -async function handler({ user: { id: userId }, params: { courseId } }) { +async function handler({ + user: { id: userId }, + params: { courseId, isInvite }, + body, +}) { const { config: { courseService: { courseServiceMessages: messages }, globals: { resources }, }, - models: { UserRole }, + models: { UserRole, Invite }, } = this; - await UserRole.enrollToResource({ - userId, - resourceId: courseId, - resourceType: resources.COURSE.name, - resourceStatuses: resources.COURSE.enrollStatuses, + await UserRole.transaction(async (trx) => { + await UserRole.enrollToResource({ + trx, + userId, + resourceId: courseId, + resourceType: resources.COURSE.name, + resourceStatuses: body?.invite + ? [...resources.COURSE.enrollStatuses, 'Private'] + : resources.COURSE.enrollStatuses, + }); + + if (isInvite) { + await Invite.setInviteSuccess({ trx, inviteId: body.invite }); + } }); return { message: messages.COURSE_MSG_SUCCESS_ENROLL }; diff --git a/api/src/services/email/models/Email.js b/api/src/services/email/models/Email.js index e358f0ec..fa75bba6 100644 --- a/api/src/services/email/models/Email.js +++ b/api/src/services/email/models/Email.js @@ -81,6 +81,14 @@ class Email { html: this.t('email:email_confirmation.html', { link, lng: language }), }); } + + async sendInvite({ email, language = 'en', resourceType, link }) { + return this.sendMailWithLogging({ + to: email, + subject: this.t('email:invite.subject', { lng: language }), + html: this.t('email:invite.html', { lng: language, resourceType, link }), + }); + } } export default Email; diff --git a/api/src/services/invites/controllers/createInvite.js b/api/src/services/invites/controllers/createInvite.js index 628cc3af..74b82e92 100644 --- a/api/src/services/invites/controllers/createInvite.js +++ b/api/src/services/invites/controllers/createInvite.js @@ -34,6 +34,10 @@ const options = { roleId: roles.MAINTAINER.id, }); + if (body.emails.length) { + body.emails.map((email) => this.validateEmail({ email })); + } + const isLesson = body.resourceType === resources.LESSON.name; await (isLesson ? Lesson : Course) .query() @@ -46,61 +50,66 @@ const options = { }, }; -async function handler({ body }) { +async function sendInvites({ emailModel, data, host }) { + return Promise.all( + data.map(async (invite) => + emailModel.sendInvite({ + email: invite.email, + link: `${host}/invite=${invite.id}`, + }), + ), + ); +} + +async function handler({ body, headers }) { const { models: { Invite }, + config: { + globals: { invitesStatuses }, + }, + emailModel, } = this; - // trx - let data; - if (body.emails.length) { - await Invite.query() - .patch({ status: 'revoked' }) - .where({ - resource_id: body.resourceId, - resource_type: body.resourceType, - status: 'pending', - }) - .whereIn('email', body.emails) - .returning('*'); + const invites = await Invite.transaction(async (trx) => { + await Invite.revokeInvites({ + trx, + resourceId: body.resourceId, + resourceType: body.resourceType, + emails: body.emails, + }); - data = body.emails.map((email) => ({ - resource_id: body.resourceId, - resource_type: body.resourceType, - status: 'pending', - email, - })); - } else { - await Invite.query() - .patch({ status: 'revoked' }) - .where({ - resource_id: body.resourceId, - resource_type: body.resourceType, - status: 'pending', - }) - .whereNull('email') - .returning('*'); + const data = body.emails.length + ? body.emails.map((email) => ({ + resource_id: body.resourceId, + resource_type: body.resourceType, + status: invitesStatuses.PENDING, + email, + })) + : { + resource_id: body.resourceId, + resource_type: body.resourceType, + status: invitesStatuses.PENDING, + }; - data = { - resource_id: body.resourceId, - resource_type: body.resourceType, - status: 'pending', - }; - } + const createdInvites = await Invite.createInvites({ trx, data }); - const invites = await Invite.query() - .skipUndefined() - .insert(data) - .returning('*'); + if (data.length) { + const host = headers['x-forwarded-host']; + await sendInvites({ emailModel, data: createdInvites, host }); + } - // send email here + return createdInvites; + }); - return { - invites: invites.map?.((invite) => ({ - email: invite.email, - invite: invite.id, - })) || [{ email: invites.email, invite: invites.id }], - }; + if (invites.length) { + return { + invites: invites.map((invite) => ({ + email: invite.email, + invite: invite.id, + })), + }; + } + return { invites: [{ email: invites.email, invite: invites.id }] }; } export default { options, handler }; diff --git a/api/src/services/invites/controllers/getInvite.js b/api/src/services/invites/controllers/getInvite.js index d3d4f482..7f9123f3 100644 --- a/api/src/services/invites/controllers/getInvite.js +++ b/api/src/services/invites/controllers/getInvite.js @@ -1,5 +1,3 @@ -import { NotFoundError } from '../../../validation/errors'; - const options = { schema: { params: { @@ -17,10 +15,8 @@ async function handler({ params }) { models: { Invite, User }, } = this; - const invite = await Invite.query() - .findById(params.inviteId) - .throwIfNotFound({ errors: new NotFoundError('err') }); - const user = await User.query().first().where({ email: invite.email }); + const invite = await Invite.getInviteById({ inviteId: params.inviteId }); + const user = await User.getUserByEmail({ email: invite.email }); return { invite: invite.id, diff --git a/api/src/services/invites/controllers/resourceInvites.js b/api/src/services/invites/controllers/resourceInvites.js index 674cc3e0..f57be92b 100644 --- a/api/src/services/invites/controllers/resourceInvites.js +++ b/api/src/services/invites/controllers/resourceInvites.js @@ -16,9 +16,9 @@ async function handler({ params }) { models: { Invite }, } = this; - const invites = await Invite.query().where({ - resource_id: params.resourceId, - resource_type: params.resourceType, + const invites = await Invite.getResourceInvites({ + resourceId: params.resourceId, + resourceType: params.resourceType, }); return { invites }; diff --git a/api/src/services/invites/controllers/revokeInvite.js b/api/src/services/invites/controllers/revokeInvite.js index 144a7ae9..39481be0 100644 --- a/api/src/services/invites/controllers/revokeInvite.js +++ b/api/src/services/invites/controllers/revokeInvite.js @@ -1,5 +1,3 @@ -import { BadRequestError } from '../../../validation/errors'; - const options = { schema: { params: { @@ -21,9 +19,7 @@ const options = { }, } = this; - const invite = await Invite.query() - .findById(params.inviteId) - .throwIfNotFound({ error: new BadRequestError('invalid invite') }); + const invite = await Invite.getInviteById({ inviteId: params.inviteId }); await this.access({ userId: user.id, @@ -37,11 +33,14 @@ const options = { async function handler({ params }) { const { models: { Invite }, + config: { + invitesService: { invitesServiceMessages: messages }, + }, } = this; - await Invite.query().findById(params.inviteId).patch({ status: 'revoked' }); + await Invite.revokeOneInvite({ inviteId: params.inviteId }); - return { message: 'success' }; + return { message: messages.INVITE_MSG_REVOKE_SUCCESS }; } export default { options, handler }; diff --git a/api/src/services/invites/hooks/index.js b/api/src/services/invites/hooks/index.js new file mode 100644 index 00000000..c3e0d3c1 --- /dev/null +++ b/api/src/services/invites/hooks/index.js @@ -0,0 +1,3 @@ +import processInvite from './processInvite'; + +export { processInvite }; diff --git a/api/src/services/invites/hooks/processInvite.js b/api/src/services/invites/hooks/processInvite.js new file mode 100644 index 00000000..aaea2b9e --- /dev/null +++ b/api/src/services/invites/hooks/processInvite.js @@ -0,0 +1,27 @@ +import { BadRequestError } from '../../../validation/errors'; + +export default async function processInvite({ req, resourceType, resourceId }) { + const { + models: { Invite }, + config: { + lessonService: { lessonServiceErrors: errors }, + }, + } = this; + + const { body, params, user } = req; + + if (body?.invite) { + const invite = await Invite.checkIfPendingInvite({ + inviteId: body.invite, + resourceId, + resourceType, + }); + + if (invite.email) { + params.isInvite = true; + if (invite.email !== user.email) { + throw new BadRequestError(errors.LESSON_ERR_FAIL_ENROLL); + } + } + } +} diff --git a/api/src/services/invites/index.js b/api/src/services/invites/index.js index b8feadea..f38767a7 100644 --- a/api/src/services/invites/index.js +++ b/api/src/services/invites/index.js @@ -1,9 +1,11 @@ import fp from 'fastify-plugin'; import { router } from './routes'; +import { processInvite } from './hooks'; const invitesService = (instance, opts, done) => { instance.register(router, opts); + instance.decorate('processInvite', processInvite); done(); }; diff --git a/api/src/services/lessons/controllers/enrollLesson.js b/api/src/services/lessons/controllers/enrollLesson.js index 9f03c79c..de9280a8 100644 --- a/api/src/services/lessons/controllers/enrollLesson.js +++ b/api/src/services/lessons/controllers/enrollLesson.js @@ -1,5 +1,3 @@ -import { BadRequestError } from '../../../validation/errors'; - const options = { schema: { params: { $ref: 'paramsLessonId#' }, @@ -20,34 +18,15 @@ const options = { }, async preHandler(req) { const { - models: { Invite }, config: { - lessonService: { lessonServiceErrors: errors }, + globals: { resources }, }, } = this; - - const { body, params, user } = req; - - if (body?.invite) { - const invite = await Invite.query() - .first() - .where({ - id: body.invite, - resource_id: params.lessonId, - resource_type: 'lesson', - status: 'pending', - }) - .throwIfNotFound({ - error: new BadRequestError(errors.LESSON_ERR_FAIL_ENROLL), - }); - - if (invite.email) { - req.params.isInvite = true; - if (invite.email !== user.email) { - throw new BadRequestError(errors.LESSON_ERR_FAIL_ENROLL); - } - } - } + await this.processInvite({ + req, + resourceType: resources.LESSON.name, + resourceId: req.params.lessonId, + }); }, }; @@ -63,19 +42,22 @@ async function handler({ }, models: { UserRole, Invite }, } = this; - // trx - await UserRole.enrollToResource({ - userId, - resourceId: lessonId, - resourceType: resources.LESSON.name, - resourceStatuses: body?.invite - ? [...resources.LESSON.enrollStatuses, 'Private'] - : resources.LESSON.enrollStatuses, - }); - if (isInvite) { - await Invite.query().findById(body.invite).patch({ status: 'success' }); - } + await UserRole.transaction(async (trx) => { + await UserRole.enrollToResource({ + trx, + userId, + resourceId: lessonId, + resourceType: resources.LESSON.name, + resourceStatuses: body?.invite + ? [...resources.LESSON.enrollStatuses, 'Private'] + : resources.LESSON.enrollStatuses, + }); + + if (isInvite) { + await Invite.setInviteSuccess({ trx, inviteId: body.invite }); + } + }); return { message: messages.LESSON_MSG_SUCCESS_ENROLL }; } diff --git a/api/test/integration/invites.spec.js b/api/test/integration/invites.spec.js index d54a5315..e8cf0591 100644 --- a/api/test/integration/invites.spec.js +++ b/api/test/integration/invites.spec.js @@ -5,8 +5,19 @@ import { studentJohn, defaultPassword, } from '../../seeds/testData/users'; -import { authorizeUser, createLesson } from './utils'; -import { lessonServiceErrors, lessonServiceMessages } from '../../src/config'; +import { + authorizeUser, + createCourse, + createLesson, + prepareCourseFromSeed, +} from './utils'; +import { + courseServiceErrors, + courseServiceMessages, + lessonServiceErrors, + lessonServiceMessages, +} from '../../src/config'; +import { courseToTest } from '../../seeds/testData/courses'; describe('Invites service test', () => { const testContext = { @@ -71,377 +82,753 @@ describe('Invites service test', () => { await testContext.app.close(); }); - describe('Private lesson should not be enrollable without an invite', () => { - let lessonToEnroll; - - beforeAll(async () => { - lessonToEnroll = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', - status: 'Private', + describe('Enroll to a lesson with an invite', () => { + describe('Private lesson should not be enrollable without an invite', () => { + let lessonToEnroll; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, }, - }, + }); + }); + + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceErrors.LESSON_ERR_FAIL_ENROLL, + ); }); }); - it('should return an error', async () => { - const response = await testContext.studentsRequest({ - url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + describe('Creation of a general link', () => { + let lessonToInvite; + + beforeAll(async () => { + lessonToInvite = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); }); - const payload = JSON.parse(response.payload); + it('should successfully create a link', async () => { + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToInvite.lesson.id, + resourceType: 'lesson', + }, + }); + + const payload = JSON.parse(response.payload); - expect(response.statusCode).toBe(400); - expect(payload).toHaveProperty('message'); - expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('invites'); + expect(payload.invites[0]).toMatchObject({ + email: null, + invite: expect.any(String), + }); + }); }); - }); - describe('Creation of a general link', () => { - let lessonToInvite; + describe('Enroll with a general link', () => { + let lessonToEnroll; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); - beforeAll(async () => { - lessonToInvite = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', - status: 'Private', + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', }, - }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; + }); + + it('should be enrollable', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, + ); }); }); - it('should successfully create a link', async () => { - const response = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToInvite.lesson.id, - resourceType: 'lesson', - }, + describe('Enroll with an invalid invite', () => { + let lessonToEnroll; + let lessonToInvite; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + lessonToInvite = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToInvite.lesson.id, + resourceType: 'lesson', + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; }); - const payload = JSON.parse(response.payload); + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); - expect(response.statusCode).toBe(200); - expect(payload).toHaveProperty('invites'); - expect(payload.invites[0]).toMatchObject({ - email: null, - invite: expect.any(String), + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceErrors.LESSON_ERR_FAIL_ENROLL, + ); }); }); - }); - describe('Enroll with a general link', () => { - let lessonToEnroll; - let invite; - - beforeAll(async () => { - lessonToEnroll = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', - status: 'Private', + describe('Creation of an invite with an email', () => { + let lessonToInvite; + + beforeAll(async () => { + lessonToInvite = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, }, - }, + }); }); - const response = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToEnroll.lesson.id, - resourceType: 'lesson', - }, - }); + it('should successfully create a link', async () => { + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToInvite.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); - const { invites } = JSON.parse(response.payload); - invite = invites[0].invite; + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('invites'); + expect(payload.invites[0]).toMatchObject({ + email: studentJohn.email, + invite: expect.any(String), + }); + }); }); - it('should be enrollable', async () => { - const response = await testContext.studentsRequest({ - url: `lessons/${lessonToEnroll.lesson.id}/enroll`, - body: { - invite, - }, + describe('Enroll with an invite', () => { + let lessonToEnroll; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; }); - const payload = JSON.parse(response.payload); + it('should be enrollable', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); - expect(response.statusCode).toBe(200); - expect(payload).toHaveProperty('message'); - expect(payload.message).toBe( - lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, - ); + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, + ); + }); }); - }); - describe('Enroll with an invalid invite', () => { - let lessonToEnroll; - let lessonToInvite; - let invite; - - beforeAll(async () => { - lessonToEnroll = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', - status: 'Private', + describe('Enroll with an invalid invite', () => { + let lessonToEnroll; + let invite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, }, - }, + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: ['student@test.io'], + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; }); - lessonToInvite = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', - status: 'Private', + + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite, }, - }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceErrors.LESSON_ERR_FAIL_ENROLL, + ); }); + }); - const response = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToInvite.lesson.id, - resourceType: 'lesson', - }, + describe('Revoking an invite by creating a new one', () => { + let lessonToEnroll; + let revokedInvite; + let workingInvite; + + beforeAll(async () => { + lessonToEnroll = await createLesson({ + app: testContext.app, + credentials: teachersCredentials, + body: { + lesson: { + name: 'Private Lesson', + status: 'Private', + }, + }, + }); + + const revokedInviteResponse = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); + + const { invites: revokedInvitesPayload } = JSON.parse( + revokedInviteResponse.payload, + ); + revokedInvite = revokedInvitesPayload[0].invite; + + const workingInviteResponse = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: lessonToEnroll.lesson.id, + resourceType: 'lesson', + emails: [studentJohn.email], + }, + }); + + const { invites: workingInvitePayload } = JSON.parse( + workingInviteResponse.payload, + ); + workingInvite = workingInvitePayload[0].invite; }); - const { invites } = JSON.parse(response.payload); - invite = invites[0].invite; - }); + it('should return an error if enroll with a revoked invite', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite: revokedInvite, + }, + }); - it('should return an error', async () => { - const response = await testContext.studentsRequest({ - url: `lessons/${lessonToEnroll.lesson.id}/enroll`, - body: { - invite, - }, + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceErrors.LESSON_ERR_FAIL_ENROLL, + ); }); - const payload = JSON.parse(response.payload); + it('should successfully enroll with a working invite', async () => { + const response = await testContext.studentsRequest({ + url: `lessons/${lessonToEnroll.lesson.id}/enroll`, + body: { + invite: workingInvite, + }, + }); + + const payload = JSON.parse(response.payload); - expect(response.statusCode).toBe(400); - expect(payload).toHaveProperty('message'); - expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, + ); + }); }); }); - describe('Creation of an invite with an email', () => { - let lessonToInvite; + describe('Enroll to a course with an invite', () => { + describe('Private course should not be enrollable without an invite', () => { + let courseToEnroll; - beforeAll(async () => { - lessonToInvite = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', + beforeAll(async () => { + courseToEnroll = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ status: 'Private', - }, - }, + seed: courseToTest, + }), + }); }); - }); - it('should successfully create a link', async () => { - const response = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToInvite.lesson.id, - resourceType: 'lesson', - emails: [studentJohn.email], - }, - }); + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `courses/${courseToEnroll.course.id}/enroll`, + }); - const payload = JSON.parse(response.payload); + const payload = JSON.parse(response.payload); - expect(response.statusCode).toBe(200); - expect(payload).toHaveProperty('invites'); - expect(payload.invites[0]).toMatchObject({ - email: studentJohn.email, - invite: expect.any(String), + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + lessonServiceErrors.LESSON_ERR_FAIL_ENROLL, + ); }); }); - }); - describe('Enroll with an invite', () => { - let lessonToEnroll; - let invite; - - beforeAll(async () => { - lessonToEnroll = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', + describe('Creation of a general link', () => { + let courseToInvite; + + beforeAll(async () => { + courseToInvite = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ status: 'Private', - }, - }, + seed: courseToTest, + }), + }); }); - const response = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToEnroll.lesson.id, - resourceType: 'lesson', - emails: [studentJohn.email], - }, - }); + it('should successfully create a link', async () => { + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToInvite.course.id, + resourceType: 'course', + }, + }); - const { invites } = JSON.parse(response.payload); - invite = invites[0].invite; + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('invites'); + expect(payload.invites[0]).toMatchObject({ + email: null, + invite: expect.any(String), + }); + }); }); - it('should be enrollable', async () => { - const response = await testContext.studentsRequest({ - url: `lessons/${lessonToEnroll.lesson.id}/enroll`, - body: { - invite, - }, + describe('Enroll with a general link', () => { + let courseToEnroll; + let invite; + + beforeAll(async () => { + courseToEnroll = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ + status: 'Private', + seed: courseToTest, + }), + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToEnroll.course.id, + resourceType: 'course', + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; }); - const payload = JSON.parse(response.payload); + it('should be enrollable', async () => { + const response = await testContext.studentsRequest({ + url: `courses/${courseToEnroll.course.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); - expect(response.statusCode).toBe(200); - expect(payload).toHaveProperty('message'); - expect(payload.message).toBe( - lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, - ); + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + courseServiceMessages.COURSE_MSG_SUCCESS_ENROLL, + ); + }); }); - }); - describe('Enroll with an invalid invite', () => { - let lessonToEnroll; - let invite; - - beforeAll(async () => { - lessonToEnroll = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', + describe('Enroll with an invalid invite', () => { + let courseToEnroll; + let courseToInvite; + let invite; + + beforeAll(async () => { + courseToEnroll = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ + status: 'Private', + seed: courseToTest, + }), + }); + courseToInvite = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ status: 'Private', + seed: courseToTest, + }), + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToInvite.course.id, + resourceType: 'course', }, - }, - }); + }); - const response = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToEnroll.lesson.id, - resourceType: 'lesson', - emails: ['student@test.io'], - }, + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; }); - const { invites } = JSON.parse(response.payload); - invite = invites[0].invite; + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `courses/${courseToEnroll.course.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + courseServiceErrors.COURSE_ERR_FAIL_ENROLL, + ); + }); }); - it('should return an error', async () => { - const response = await testContext.studentsRequest({ - url: `lessons/${lessonToEnroll.lesson.id}/enroll`, - body: { - invite, - }, + describe('Creation of an invite with an email', () => { + let courseToInvite; + + beforeAll(async () => { + courseToInvite = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ + status: 'Private', + seed: courseToTest, + }), + }); }); - const payload = JSON.parse(response.payload); + it('should successfully create a link', async () => { + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToInvite.course.id, + resourceType: 'course', + emails: [studentJohn.email], + }, + }); - expect(response.statusCode).toBe(400); - expect(payload).toHaveProperty('message'); - expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('invites'); + expect(payload.invites[0]).toMatchObject({ + email: studentJohn.email, + invite: expect.any(String), + }); + }); }); - }); - describe('Revoking an invite by creating a new one', () => { - let lessonToEnroll; - let revokedInvite; - let workingInvite; - - beforeAll(async () => { - lessonToEnroll = await createLesson({ - app: testContext.app, - credentials: teachersCredentials, - body: { - lesson: { - name: 'Private Lesson', + describe('Enroll with an invite', () => { + let courseToEnroll; + let invite; + + beforeAll(async () => { + courseToEnroll = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ status: 'Private', + seed: courseToTest, + }), + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToEnroll.course.id, + resourceType: 'course', + emails: [studentJohn.email], }, - }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; }); - const revokedInviteResponse = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToEnroll.lesson.id, - resourceType: 'lesson', - emails: [studentJohn.email], - }, + it('should be enrollable', async () => { + const response = await testContext.studentsRequest({ + url: `courses/${courseToEnroll.course.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + courseServiceMessages.COURSE_MSG_SUCCESS_ENROLL, + ); }); + }); - const { invites: revokedInvitesPayload } = JSON.parse( - revokedInviteResponse.payload, - ); - revokedInvite = revokedInvitesPayload[0].invite; + describe('Enroll with an invalid invite', () => { + let courseToEnroll; + let invite; - const workingInviteResponse = await testContext.teachersRequest({ - url: `invites`, - body: { - resourceId: lessonToEnroll.lesson.id, - resourceType: 'lesson', - emails: [studentJohn.email], - }, + beforeAll(async () => { + courseToEnroll = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ + status: 'Private', + seed: courseToTest, + }), + }); + + const response = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToEnroll.course.id, + resourceType: 'course', + emails: ['student@test.io'], + }, + }); + + const { invites } = JSON.parse(response.payload); + invite = invites[0].invite; }); - const { invites: workingInvitePayload } = JSON.parse( - workingInviteResponse.payload, - ); - workingInvite = workingInvitePayload[0].invite; + it('should return an error', async () => { + const response = await testContext.studentsRequest({ + url: `courses/${courseToEnroll.course.id}/enroll`, + body: { + invite, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + courseServiceErrors.COURSE_ERR_FAIL_ENROLL, + ); + }); }); - it('should return an error if enroll with a revoked invite', async () => { - const response = await testContext.studentsRequest({ - url: `lessons/${lessonToEnroll.lesson.id}/enroll`, - body: { - invite: revokedInvite, - }, + describe('Revoking an invite by creating a new one', () => { + let courseToEnroll; + let revokedInvite; + let workingInvite; + + beforeAll(async () => { + courseToEnroll = await createCourse({ + app: testContext.app, + credentials: teachersCredentials, + body: prepareCourseFromSeed({ + status: 'Private', + seed: courseToTest, + }), + }); + + const revokedInviteResponse = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToEnroll.course.id, + resourceType: 'course', + emails: [studentJohn.email], + }, + }); + + const { invites: revokedInvitesPayload } = JSON.parse( + revokedInviteResponse.payload, + ); + revokedInvite = revokedInvitesPayload[0].invite; + + const workingInviteResponse = await testContext.teachersRequest({ + url: `invites`, + body: { + resourceId: courseToEnroll.course.id, + resourceType: 'course', + emails: [studentJohn.email], + }, + }); + + const { invites: workingInvitePayload } = JSON.parse( + workingInviteResponse.payload, + ); + workingInvite = workingInvitePayload[0].invite; }); - const payload = JSON.parse(response.payload); + it('should return an error if enroll with a revoked invite', async () => { + const response = await testContext.studentsRequest({ + url: `courses/${courseToEnroll.course.id}/enroll`, + body: { + invite: revokedInvite, + }, + }); - expect(response.statusCode).toBe(400); - expect(payload).toHaveProperty('message'); - expect(payload.message).toBe(lessonServiceErrors.LESSON_ERR_FAIL_ENROLL); - }); + const payload = JSON.parse(response.payload); - it('should successfully enroll with a working invite', async () => { - const response = await testContext.studentsRequest({ - url: `lessons/${lessonToEnroll.lesson.id}/enroll`, - body: { - invite: workingInvite, - }, + expect(response.statusCode).toBe(400); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + courseServiceErrors.COURSE_ERR_FAIL_ENROLL, + ); }); - const payload = JSON.parse(response.payload); + it('should successfully enroll with a working invite', async () => { + const response = await testContext.studentsRequest({ + url: `courses/${courseToEnroll.course.id}/enroll`, + body: { + invite: workingInvite, + }, + }); + + const payload = JSON.parse(response.payload); - expect(response.statusCode).toBe(200); - expect(payload).toHaveProperty('message'); - expect(payload.message).toBe( - lessonServiceMessages.LESSON_MSG_SUCCESS_ENROLL, - ); + expect(response.statusCode).toBe(200); + expect(payload).toHaveProperty('message'); + expect(payload.message).toBe( + courseServiceMessages.COURSE_MSG_SUCCESS_ENROLL, + ); + }); }); }); }); From 94e6504e5724da7f2ad2ede1048f0cd37a3fd808 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Wed, 27 Oct 2021 09:39:09 +0300 Subject: [PATCH 3/4] fix: PR comments --- api/src/services/invites/controllers/createInvite.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/services/invites/controllers/createInvite.js b/api/src/services/invites/controllers/createInvite.js index 74b82e92..354fd7f5 100644 --- a/api/src/services/invites/controllers/createInvite.js +++ b/api/src/services/invites/controllers/createInvite.js @@ -94,8 +94,11 @@ async function handler({ body, headers }) { const createdInvites = await Invite.createInvites({ trx, data }); if (data.length) { - const host = headers['x-forwarded-host']; - await sendInvites({ emailModel, data: createdInvites, host }); + await sendInvites({ + emailModel, + data: createdInvites, + host: process.env.SB_HOST, + }); } return createdInvites; From 2d85675d456d3fc0ffb6a4ac53d07c38a3132f1d Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Fri, 29 Oct 2021 15:46:44 +0300 Subject: [PATCH 4/4] fix: PR comments --- api/src/services/email/models/Email.js | 9 +++++---- api/src/services/invites/controllers/createInvite.js | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/api/src/services/email/models/Email.js b/api/src/services/email/models/Email.js index fa75bba6..e26bb7b7 100644 --- a/api/src/services/email/models/Email.js +++ b/api/src/services/email/models/Email.js @@ -1,5 +1,6 @@ import nodemailer from 'nodemailer'; import { EMAIL_SETTINGS } from '../../../config'; +import { DEFAULT_LANGUAGE } from '../../../config/i18n'; const { fromName, host } = EMAIL_SETTINGS; @@ -58,7 +59,7 @@ class Email { } } - async sendResetPassword({ email, link, language = 'en' }) { + async sendResetPassword({ email, link, language = DEFAULT_LANGUAGE }) { return this.sendMailWithLogging({ to: email, subject: this.t('email:password_reset.subject', { lng: language }), @@ -66,7 +67,7 @@ class Email { }); } - async sendPasswordChanged({ email, language = 'en' }) { + async sendPasswordChanged({ email, language = DEFAULT_LANGUAGE }) { return this.sendMailWithLogging({ to: email, subject: this.t('email:password_changed.subject', { lng: language }), @@ -74,7 +75,7 @@ class Email { }); } - async sendEmailConfirmation({ email, link, language = 'en' }) { + async sendEmailConfirmation({ email, link, language = DEFAULT_LANGUAGE }) { return this.sendMailWithLogging({ to: email, subject: this.t('email:email_confirmation.subject', { lng: language }), @@ -82,7 +83,7 @@ class Email { }); } - async sendInvite({ email, language = 'en', resourceType, link }) { + async sendInvite({ email, language = DEFAULT_LANGUAGE, resourceType, link }) { return this.sendMailWithLogging({ to: email, subject: this.t('email:invite.subject', { lng: language }), diff --git a/api/src/services/invites/controllers/createInvite.js b/api/src/services/invites/controllers/createInvite.js index 354fd7f5..83040f03 100644 --- a/api/src/services/invites/controllers/createInvite.js +++ b/api/src/services/invites/controllers/createInvite.js @@ -55,13 +55,13 @@ async function sendInvites({ emailModel, data, host }) { data.map(async (invite) => emailModel.sendInvite({ email: invite.email, - link: `${host}/invite=${invite.id}`, + link: `${host}/invite/${invite.id}`, }), ), ); } -async function handler({ body, headers }) { +async function handler({ body }) { const { models: { Invite }, config: {