Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions api/migrations/20211014105721_create_invites_table.js
Original file line number Diff line number Diff line change
@@ -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');
7 changes: 7 additions & 0 deletions api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -71,6 +73,7 @@ export default (options = {}) => {
ResourceKeyword,
File,
ResourceFile,
Invite,
],
});

Expand Down Expand Up @@ -110,5 +113,9 @@ export default (options = {}) => {
prefix: '/api/v1/email',
});

app.register(invitesService, {
prefix: '/api/v1/invites',
});

return app;
};
6 changes: 6 additions & 0 deletions api/src/config/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
3 changes: 3 additions & 0 deletions api/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +13,7 @@ export default {
lessonService,
courseService,
fileService,
invitesService,
};

export * from './globals';
Expand All @@ -20,3 +22,4 @@ export * from './lessonService';
export * from './courseService';
export * from './fileService';
export * from './emailService';
export * from './invitesService';
7 changes: 7 additions & 0 deletions api/src/config/invitesService.js
Original file line number Diff line number Diff line change
@@ -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',
};
4 changes: 4 additions & 0 deletions api/src/i18n/locales/en/email.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
}
4 changes: 4 additions & 0 deletions api/src/i18n/locales/ru/email.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@
"email_confirmation": {
"subject": "Email подтверждение",
"html": "Подтвердите ссылку на электронную почту {{link}}"
},
"invite": {
"subject": "Вас пригласили начать обучение на StudyBites",
"html": "Пройдите по ссылке, чтобы начать: {{link}}"
}
}
94 changes: 94 additions & 0 deletions api/src/models/Invite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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() {
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' },
},
};
}

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;
3 changes: 2 additions & 1 deletion api/src/models/UserRole.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 32 additions & 7 deletions api/src/services/courses/controllers/enrollCourse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion api/src/services/email/controllers/updatePassword.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
15 changes: 12 additions & 3 deletions api/src/services/email/models/Email.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import nodemailer from 'nodemailer';
import { EMAIL_SETTINGS } from '../../../config';
import { DEFAULT_LANGUAGE } from '../../../config/i18n';

const { fromName, host } = EMAIL_SETTINGS;

Expand Down Expand Up @@ -58,29 +59,37 @@ 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 }),
html: this.t('email:password_reset.html', { link, lng: language }),
});
}

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 }),
html: this.t('email:password_changed.html', { lng: language }),
});
}

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 }),
html: this.t('email:email_confirmation.html', { link, lng: language }),
});
}

async sendInvite({ email, language = DEFAULT_LANGUAGE, 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;
Loading