From 06e8acd0b25740253e52d585894060f45f117948 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Tue, 7 Jun 2022 00:51:03 +0300 Subject: [PATCH 1/9] fix: lesson deleting --- app/Repositories/LessonRepository.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Repositories/LessonRepository.ts b/app/Repositories/LessonRepository.ts index a4c8688..4875996 100644 --- a/app/Repositories/LessonRepository.ts +++ b/app/Repositories/LessonRepository.ts @@ -202,7 +202,10 @@ export default class LessonRepository { /** * Delete files from disk */ - await this.Drive.delete(`videos/${lesson.video.name}`); + if (lesson.video) { + await this.Drive.delete(`videos/${lesson.video.name}`); + } + await this.deleteMaterials(lesson); /** From b6217a40075544d3b278ca5133dd81a1fce68d06 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Wed, 8 Jun 2022 23:08:09 +0300 Subject: [PATCH 2/9] refactor: moved bouncer actions to policies --- .../Http/Api/v1/UsersController.ts | 2 +- app/Policies/LessonPolicy.ts | 35 +++++++++ app/Policies/RolePolicy.ts | 36 ++++++++++ app/Services/CourseService.ts | 14 ++-- app/Services/LessonService.ts | 71 ++----------------- app/Services/UserService.ts | 41 ++++------- start/bouncer.ts | 27 ++----- start/routes/apis/v1/users.ts | 2 +- 8 files changed, 106 insertions(+), 122 deletions(-) create mode 100644 app/Policies/LessonPolicy.ts create mode 100644 app/Policies/RolePolicy.ts diff --git a/app/Controllers/Http/Api/v1/UsersController.ts b/app/Controllers/Http/Api/v1/UsersController.ts index 3f03c28..3219071 100644 --- a/app/Controllers/Http/Api/v1/UsersController.ts +++ b/app/Controllers/Http/Api/v1/UsersController.ts @@ -93,7 +93,7 @@ export default class UsersController extends BaseController { */ public async delete(ctx: HttpContextContract) { - const result = await this.userService.deleteUser(ctx.params.id); + const result = await this.userService.deleteUser(ctx.params.id, ctx); if (!result.success && result.error) { throw new Exception(result.message, result.status, result.error.code); diff --git a/app/Policies/LessonPolicy.ts b/app/Policies/LessonPolicy.ts new file mode 100644 index 0000000..16cb1fd --- /dev/null +++ b/app/Policies/LessonPolicy.ts @@ -0,0 +1,35 @@ +import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'; + +/** + * Helpers + */ +import RoleHelper from 'App/Helpers/RoleHelper'; + +/** + * Models + */ +import Lesson from 'App/Models/Lesson'; +import User from 'App/Models/User'; + +/** + * Datatypes + */ +import RoleEnum from 'App/Datatypes/Enums/RoleEnum'; + +export default class LessonPolicy extends BasePolicy { + public async view(user: User, lesson: Lesson) { + /** + * Load required data to check permissions + */ + await user.load(loader => loader.load('roles').load('courses')); + await lesson.load('course'); + + const isAdminOrTeacher = RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]); + + if (isAdminOrTeacher) { + return true; + } + + return !!user.courses.find(course => course.id === lesson.course.id); + } +} diff --git a/app/Policies/RolePolicy.ts b/app/Policies/RolePolicy.ts new file mode 100644 index 0000000..10840a8 --- /dev/null +++ b/app/Policies/RolePolicy.ts @@ -0,0 +1,36 @@ +import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'; + +/** + * Models + */ +import User from 'App/Models/User'; + +/** + * Helpers + */ +import RoleHelper from 'App/Helpers/RoleHelper'; + +/** + * Datatypes + */ +import RoleEnum from 'App/Datatypes/Enums/RoleEnum'; + +export default class RolePolicy extends BasePolicy { + public async manage(user: User, roles: Array) { + await user.load('roles'); + + const isAdmin = RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN]); + const isTeacher = RoleHelper.userContainRoles(user.roles, [RoleEnum.TEACHER]); + + if (isAdmin) { + return true; + } + + /** + * User with role `TEACHER` can manage only user with `STUDENT` role + */ + if (!(roles.includes(RoleEnum.TEACHER) || roles.includes(RoleEnum.ADMIN)) && isTeacher) { + return true; + } + } +} diff --git a/app/Services/CourseService.ts b/app/Services/CourseService.ts index 9e7903e..35b0efa 100644 --- a/app/Services/CourseService.ts +++ b/app/Services/CourseService.ts @@ -234,9 +234,9 @@ export default class CourseService { /** * Find course teacher */ - const teacher = await this.userRepository.getById(data.teacher_id); + const user = await this.userRepository.getById(data.teacher_id); - if (!teacher) { + if (!user) { return { success: false, status: HttpStatusEnum.NOT_FOUND, @@ -248,13 +248,13 @@ export default class CourseService { }; } - const isTeacher = RoleHelper.userHasRoles(teacher.roles, [RoleEnum.TEACHER]); + const isHasPermissions = RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]); - if (!isTeacher) { + if (!isHasPermissions) { return { success: false, status: HttpStatusEnum.BAD_REQUEST, - message: `User with id "${teacher.id}" not a teacher.`, + message: `User with id "${user.id}" does not have sufficient permissions.`, data: {}, error: { code: 'E_BAD_REQUEST', @@ -359,6 +359,10 @@ export default class CourseService { }; } + // if (data.teacher_id) { + + // } + return { success: true, status: HttpStatusEnum.OK, diff --git a/app/Services/LessonService.ts b/app/Services/LessonService.ts index cf0fe3b..4f3062e 100644 --- a/app/Services/LessonService.ts +++ b/app/Services/LessonService.ts @@ -78,20 +78,7 @@ export default class LessonService { }; } - /** - * Allow user to view lesson content - */ - if (await ctx.bouncer.denies('viewLessonContent', lesson)) { - return { - success: false, - status: HttpStatusEnum.FORBIDDEN, - message: 'The user is not a student of this course.', - data: {}, - error: { - code: 'E_FORBIDDEN', - }, - }; - } + await ctx.bouncer.with('LessonPolicy').authorize('view', lesson); /** * Load lesson with materials @@ -109,7 +96,8 @@ export default class LessonService { /** * Fetch lesson material by file name * - * @param fileName Name of file + * @param ctx Http context + * @param name Filename * @returns Response */ public async fetchMaterialFile(ctx: HttpContextContract, name: string): Promise> { @@ -131,18 +119,7 @@ export default class LessonService { * Allow user to view lesson material */ await material.load('lesson'); - - if (await ctx.bouncer.denies('viewLessonContent', material.lesson)) { - return { - success: false, - status: HttpStatusEnum.FORBIDDEN, - message: 'You are not able to view this file.', - data: {}, - error: { - code: 'E_FORBIDDEN', - }, - }; - } + await ctx.bouncer.with('LessonPolicy').authorize('view', material.lesson); return { success: true, @@ -168,18 +145,7 @@ export default class LessonService { } await video.load('lesson'); - - if (await ctx.bouncer.denies('viewLessonContent', video.lesson)) { - return { - success: false, - status: HttpStatusEnum.FORBIDDEN, - message: 'You are not able to view this file.', - data: {}, - error: { - code: 'E_FORBIDDEN', - }, - }; - } + await ctx.bouncer.with('LessonPolicy').authorize('view', video.lesson); return { success: true, @@ -211,17 +177,7 @@ export default class LessonService { }; } - if (await ctx.bouncer.denies('viewLessonContent', lesson)) { - return { - success: false, - status: HttpStatusEnum.FORBIDDEN, - message: 'The user is not a student of this course.', - data: {}, - error: { - code: 'E_FORBIDDEN', - }, - }; - } + await ctx.bouncer.with('LessonPolicy').authorize('view', lesson); const progress = await this.lessonProgressRepository.get(user.id, lesson.id); @@ -354,20 +310,7 @@ export default class LessonService { }; } - /** - * Allow user to view lesson content - */ - if (await ctx.bouncer.denies('viewLessonContent', lesson)) { - return { - success: false, - status: HttpStatusEnum.FORBIDDEN, - message: 'The user is not a student of this course.', - data: {}, - error: { - code: 'E_FORBIDDEN', - }, - }; - } + await ctx.bouncer.with('LessonPolicy').authorize('view', lesson); /** * Load lesson with materials diff --git a/app/Services/UserService.ts b/app/Services/UserService.ts index df95a08..432a828 100644 --- a/app/Services/UserService.ts +++ b/app/Services/UserService.ts @@ -27,6 +27,7 @@ import UserRepository from 'App/Repositories/UserRepository'; */ import CreateUserValidator from 'App/Validators/User/CreateUserValidator'; import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator'; +import RoleEnum from 'App/Datatypes/Enums/RoleEnum'; @inject() export default class UserService { @@ -120,20 +121,7 @@ export default class UserService { }; } - /** - * Check user permissions - */ - if (await ctx.bouncer.denies('manageUserRole', role)) { - return { - success: false, - status: HttpStatusEnum.FORBIDDEN, - message: 'You dont have permissions to permorm that action', - data: {}, - error: { - code: 'E_FORBIDDEN', - }, - }; - } + await ctx.bouncer.with('RolePolicy').authorize('manage', [role.slug as RoleEnum]); /** * Create new user @@ -166,7 +154,7 @@ export default class UserService { data: UpdateUserValidator['schema']['props'], ctx: HttpContextContract ): Promise { - const user = await this.userRepository.update(id, data); + const user = await this.userRepository.getById(id); if (!user) { return { @@ -180,6 +168,9 @@ export default class UserService { }; } + await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]); + await this.userRepository.update(id, data); + /** * Update user role */ @@ -187,18 +178,7 @@ export default class UserService { const role = await this.roleRepository.getBySlug(data.role); if (role) { - if (await ctx.bouncer.denies('manageUserRole', role)) { - return { - success: false, - status: HttpStatusEnum.FORBIDDEN, - message: 'You dont have permissions to permorm that action', - data: {}, - error: { - code: 'E_FORBIDDEN', - }, - }; - } - + await ctx.bouncer.with('RolePolicy').authorize('manage', [data.role]); await this.userRepository.updateRoles(user, [role]); } } @@ -217,8 +197,8 @@ export default class UserService { * @param id User id * @returns Response */ - public async deleteUser(id: string | number): Promise { - const user = await this.userRepository.delete(id); + public async deleteUser(id: string | number, ctx: HttpContextContract): Promise { + const user = await this.userRepository.getById(id); if (!user) { return { @@ -232,6 +212,9 @@ export default class UserService { }; } + await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]); + await this.userRepository.delete(id); + return { success: true, status: HttpStatusEnum.OK, diff --git a/start/bouncer.ts b/start/bouncer.ts index 6493865..0755d17 100644 --- a/start/bouncer.ts +++ b/start/bouncer.ts @@ -6,11 +6,6 @@ */ import Bouncer from '@ioc:Adonis/Addons/Bouncer'; -import RoleEnum from 'App/Datatypes/Enums/RoleEnum'; -import RoleHelper from 'App/Helpers/RoleHelper'; -import Lesson from 'App/Models/Lesson'; -import Role from 'App/Models/Role'; -import User from 'App/Models/User'; /* |-------------------------------------------------------------------------- @@ -34,22 +29,7 @@ import User from 'App/Models/User'; | NOTE: Always export the "actions" const from this file |**************************************************************** */ -export const { actions } = Bouncer.define('manageUserRole', async (user: User, role: Role) => { - await user.load('roles'); - return !( - (role.slug === RoleEnum.ADMIN || role.slug === RoleEnum.TEACHER) && - !RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN]) - ); -}).define('viewLessonContent', async (user: User, lesson: Lesson) => { - await user.load(loader => loader.load('roles').load('courses')); - await lesson.load('course'); - - if (!RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER])) { - return !!user.courses.find(course => course.id === lesson.course.id); - } - - return true; -}); +export const { actions } = Bouncer; /* |-------------------------------------------------------------------------- @@ -74,4 +54,7 @@ export const { actions } = Bouncer.define('manageUserRole', async (user: User, r | NOTE: Always export the "policies" const from this file |**************************************************************** */ -export const { policies } = Bouncer.registerPolicies({}); +export const { policies } = Bouncer.registerPolicies({ + LessonPolicy: () => import('App/Policies/LessonPolicy'), + RolePolicy: () => import('App/Policies/RolePolicy'), +}); diff --git a/start/routes/apis/v1/users.ts b/start/routes/apis/v1/users.ts index a82f47d..0ea165d 100644 --- a/start/routes/apis/v1/users.ts +++ b/start/routes/apis/v1/users.ts @@ -7,7 +7,7 @@ Route.group(() => { Route.patch('/:id', 'Api/v1/UsersController.update').middleware('role:admin,teacher').as('users.update'); Route.delete('/:id', 'Api/v1/UsersController.delete').middleware('role:admin,teacher').as('users.delete'); Route.post('/:id/attach-roles', 'Api/v1/UsersController.attachRoles') - .middleware('role:admin,teacher') + .middleware('role:admin') .as('users.attach-role'); Route.delete('/:id/detach-roles', 'Api/v1/UsersController.detachRoles') .middleware('role:admin') From 30fb10b3380eb9c9e684122998e2947717b69e1f Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Thu, 9 Jun 2022 19:02:35 +0300 Subject: [PATCH 3/9] feat: added new users to seeder --- app/Services/CourseService.ts | 36 ++++++++++++++++++++++++++++++++--- app/Services/UserService.ts | 4 ++-- database/seeders/User.ts | 31 +++++++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/app/Services/CourseService.ts b/app/Services/CourseService.ts index 35b0efa..03fc59b 100644 --- a/app/Services/CourseService.ts +++ b/app/Services/CourseService.ts @@ -345,7 +345,7 @@ export default class CourseService { * @returns Response */ public async updateCourse(id: string | number, data: UpdateCourseValidator['schema']['props']): Promise { - const course = await this.courseRepository.update(id, data); + const course = await this.courseRepository.getById(id); if (!course) { return { @@ -359,9 +359,39 @@ export default class CourseService { }; } - // if (data.teacher_id) { + if (data.teacher_id) { + const teacher = await this.userRepository.getById(data.teacher_id); - // } + if (!teacher) { + return { + success: false, + status: HttpStatusEnum.NOT_FOUND, + message: 'Teacher not found.', + data: {}, + error: { + code: 'E_NOT_FOUND', + }, + }; + } + + await teacher.load('roles'); + + const isTeacher = RoleHelper.userContainRoles(teacher.roles, [RoleEnum.TEACHER]); + + if (!isTeacher) { + return { + success: false, + status: HttpStatusEnum.BAD_REQUEST, + message: 'Author is not a teacher.', + data: {}, + error: { + code: 'E_BAD_REQUEST', + }, + }; + } + } + + await this.courseRepository.update(id, data); return { success: true, diff --git a/app/Services/UserService.ts b/app/Services/UserService.ts index 432a828..fb9641e 100644 --- a/app/Services/UserService.ts +++ b/app/Services/UserService.ts @@ -169,7 +169,7 @@ export default class UserService { } await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]); - await this.userRepository.update(id, data); + const updated = await this.userRepository.update(id, data); /** * Update user role @@ -187,7 +187,7 @@ export default class UserService { success: true, status: HttpStatusEnum.OK, message: 'User updated.', - data: user, + data: updated || {}, }; } diff --git a/database/seeders/User.ts b/database/seeders/User.ts index 07d19d2..21fec2b 100644 --- a/database/seeders/User.ts +++ b/database/seeders/User.ts @@ -11,23 +11,48 @@ export default class UserSeeder extends BaseSeeder { */ const roles = { admin: await Role.findByOrFail('slug', 'admin'), + teacher: await Role.findByOrFail('slug', 'teacher'), student: await Role.findByOrFail('slug', 'student'), }; /** * Administrator */ - const userAdmin = await User.create({ + const administrator = await User.create({ first_name: 'John', last_name: 'Doe', login: 'admin', password: '123456', email: 'administrator@example.com', }); - await userAdmin.related('roles').attach([roles.admin.id]); + await administrator.related('roles').attach([roles.admin.id]); /** - * Students + * Teacher + */ + const teacher = await User.create({ + first_name: 'Hanna', + last_name: 'Liv', + login: 'teacher', + password: '123456', + email: 'teacher@example.com', + }); + await teacher.related('roles').attach([roles.teacher.id]); + + /** + * Student + */ + const student = await User.create({ + first_name: 'Ylfa', + last_name: 'Erna', + login: 'student', + password: '123456', + email: 'student@example.com', + }); + await student.related('roles').attach([roles.student.id]); + + /** + * Student list */ await StudentFactory.with('contacts').createMany(20); } From 7149a0f3eb7e86d8bd0a0f8181fb61d9aa52a125 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Thu, 9 Jun 2022 23:50:40 +0300 Subject: [PATCH 4/9] fix: remove course files from drive after deleting user or category --- app/Helpers/CourseHelper.ts | 28 +++++++++++++++++++++++ app/Repositories/CourseRepository.ts | 26 +++++++++++++++++++++ app/Services/CategoryService.ts | 24 ++++++++++++++++++-- app/Services/CourseService.ts | 27 ++++++++++++++++++++++ app/Services/UserService.ts | 34 +++++++++++++++++++++++++++- 5 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 app/Helpers/CourseHelper.ts diff --git a/app/Helpers/CourseHelper.ts b/app/Helpers/CourseHelper.ts new file mode 100644 index 0000000..4d13385 --- /dev/null +++ b/app/Helpers/CourseHelper.ts @@ -0,0 +1,28 @@ +import Course from 'App/Models/Course'; + +export type FileEntry = { + path: string; + name: string; +}; + +export default class CourseHelper { + public static getVideoFileNames(course: Course): Array { + const data = course.lessons.map(l => { + if (!l.video) return null; + return { path: 'videos/', name: l.video.name }; + }); + + return data; + } + + public static getMaterialFileNames(course: Course): FileEntry[] { + const data = course.lessons.map(l => l.materials.map(m => ({ path: 'materials/', name: m.name }))).flat(); + return data; + } + + public static getImageFileName(course: Course): FileEntry | null { + if (!course.image) return null; + const data = { path: 'images/courses/', name: course.image.name }; + return data; + } +} diff --git a/app/Repositories/CourseRepository.ts b/app/Repositories/CourseRepository.ts index a18020f..b78caab 100644 --- a/app/Repositories/CourseRepository.ts +++ b/app/Repositories/CourseRepository.ts @@ -32,6 +32,32 @@ export default class CourseRepository { this.Course = Course; } + /** + * Get list of courses by teacher id + * + * @param id Teacher id + * @returns Array of courses + */ + public async getByTeacherId(id: string | number): Promise { + const data = await this.Course.query() + .preload('lessons', q => q.preload('video').preload('materials')) + .where('teacher_id', id); + return data; + } + + /** + * Get list of courses by category id + * + * @param id Category id + * @returns Array of courses + */ + public async getByCategoryId(id: string | number): Promise { + const data = await this.Course.query() + .preload('lessons', q => q.preload('video').preload('materials')) + .where('category_id', id); + return data; + } + /** * Set status on course * diff --git a/app/Services/CategoryService.ts b/app/Services/CategoryService.ts index cfe78b0..9df57fd 100644 --- a/app/Services/CategoryService.ts +++ b/app/Services/CategoryService.ts @@ -10,6 +10,7 @@ import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum'; * Repositories */ import CategoryRepository from 'App/Repositories/CategoryRepository'; +import CourseRepository from 'App/Repositories/CourseRepository'; /** * Validators @@ -17,12 +18,27 @@ import CategoryRepository from 'App/Repositories/CategoryRepository'; import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator'; import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator'; +/** + * Services + */ +import CourseService from 'App/Services/CourseService'; + @inject() export default class CategoryService { private categoryRepository: CategoryRepository; - constructor(categoryRepository: CategoryRepository) { + private courseRepository: CourseRepository; + + private courseService: CourseService; + + constructor( + categoryRepository: CategoryRepository, + courseRepository: CourseRepository, + courseService: CourseService + ) { this.categoryRepository = categoryRepository; + this.courseRepository = courseRepository; + this.courseService = courseService; } /** @@ -94,7 +110,7 @@ export default class CategoryService { * @returns Deleted category */ public async deleteCategory(id: string | number): Promise { - const data = await this.categoryRepository.delete(id); + const data = await this.categoryRepository.getById(id); if (!data) { return { @@ -108,6 +124,10 @@ export default class CategoryService { }; } + const courses = await this.courseRepository.getByCategoryId(id); + await this.courseService.deleteAllFiles(courses); + await this.categoryRepository.delete(id); + return { success: true, status: HttpStatusEnum.OK, diff --git a/app/Services/CourseService.ts b/app/Services/CourseService.ts index 03fc59b..1535661 100644 --- a/app/Services/CourseService.ts +++ b/app/Services/CourseService.ts @@ -26,6 +26,7 @@ import UpdateCourseValidator from 'App/Validators/Course/UpdateCourseValidator'; import FetchCoursesValidator from 'App/Validators/Course/FetchCoursesValidator'; import Course from 'App/Models/Course'; import Drive from '@ioc:Adonis/Core/Drive'; +import CourseHelper, { FileEntry } from 'App/Helpers/CourseHelper'; @inject() export default class CourseService { @@ -720,6 +721,32 @@ export default class CourseService { data: { count }, }; } + + /** + * This function will delete all associated course files on disk + * + * @param courses List of courses + */ + public async deleteAllFiles(courses: Course[]): Promise { + /** + * Collect file names + */ + const images = courses.map(course => CourseHelper.getImageFileName(course)).filter(Boolean) as FileEntry[]; + const videos = courses + .map(course => CourseHelper.getVideoFileNames(course)) + .flat() + .filter(Boolean) as FileEntry[]; + const materials = courses.map(course => CourseHelper.getMaterialFileNames(course)).flat(); + + /** + * Remove files from disk + */ + const promises = videos + .concat(images, materials) + .map(file => Drive.delete(`${file.path}${file.name.substring(file.name.lastIndexOf('/') + 1)}`)); + + await Promise.all(promises); + } } new Ioc().make(CourseService); diff --git a/app/Services/UserService.ts b/app/Services/UserService.ts index fb9641e..ed80a93 100644 --- a/app/Services/UserService.ts +++ b/app/Services/UserService.ts @@ -22,22 +22,48 @@ import Role from 'App/Models/Role'; import RoleRepository from 'App/Repositories/RoleRepository'; import UserRepository from 'App/Repositories/UserRepository'; +/** + * Services + */ +import CourseService from './CourseService'; + /** * Validators */ import CreateUserValidator from 'App/Validators/User/CreateUserValidator'; import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator'; + +/** + * Datatypes + */ import RoleEnum from 'App/Datatypes/Enums/RoleEnum'; +/** + * Helpers + */ +import RoleHelper from 'App/Helpers/RoleHelper'; +import CourseRepository from 'App/Repositories/CourseRepository'; + @inject() export default class UserService { private userRepository: UserRepository; private roleRepository: RoleRepository; - constructor(userRepository: UserRepository, roleRepository: RoleRepository) { + private courseRepository: CourseRepository; + + private courseService: CourseService; + + constructor( + userRepository: UserRepository, + roleRepository: RoleRepository, + courseRepository: CourseRepository, + courseService: CourseService + ) { this.userRepository = userRepository; this.roleRepository = roleRepository; + this.courseRepository = courseRepository; + this.courseService = courseService; } /** @@ -213,6 +239,12 @@ export default class UserService { } await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]); + + if (RoleHelper.userContainRoles(user.roles, [RoleEnum.TEACHER, RoleEnum.ADMIN])) { + const courses = await this.courseRepository.getByTeacherId(id); + await this.courseService.deleteAllFiles(courses); + } + await this.userRepository.delete(id); return { From 0472036b66fb86b6c67c03dfe2a17cf4ddedee97 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Fri, 10 Jun 2022 00:28:11 +0300 Subject: [PATCH 5/9] refactor: refactored course service --- app/Helpers/CourseHelper.ts | 2 +- app/Services/CourseService.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/Helpers/CourseHelper.ts b/app/Helpers/CourseHelper.ts index 4d13385..369b0e6 100644 --- a/app/Helpers/CourseHelper.ts +++ b/app/Helpers/CourseHelper.ts @@ -16,7 +16,7 @@ export default class CourseHelper { } public static getMaterialFileNames(course: Course): FileEntry[] { - const data = course.lessons.map(l => l.materials.map(m => ({ path: 'materials/', name: m.name }))).flat(); + const data = course.lessons.flatMap(l => l.materials.map(m => ({ path: 'materials/', name: m.name }))); return data; } diff --git a/app/Services/CourseService.ts b/app/Services/CourseService.ts index 1535661..8a569b4 100644 --- a/app/Services/CourseService.ts +++ b/app/Services/CourseService.ts @@ -732,11 +732,8 @@ export default class CourseService { * Collect file names */ const images = courses.map(course => CourseHelper.getImageFileName(course)).filter(Boolean) as FileEntry[]; - const videos = courses - .map(course => CourseHelper.getVideoFileNames(course)) - .flat() - .filter(Boolean) as FileEntry[]; - const materials = courses.map(course => CourseHelper.getMaterialFileNames(course)).flat(); + const videos = courses.flatMap(course => CourseHelper.getVideoFileNames(course)).filter(Boolean) as FileEntry[]; + const materials = courses.flatMap(course => CourseHelper.getMaterialFileNames(course)); /** * Remove files from disk From cc36a77912419ed5b6e572aaaaf161cb79f98b8a Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Fri, 10 Jun 2022 00:36:41 +0300 Subject: [PATCH 6/9] fix: user upd --- app/Services/UserService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Services/UserService.ts b/app/Services/UserService.ts index ed80a93..7348766 100644 --- a/app/Services/UserService.ts +++ b/app/Services/UserService.ts @@ -195,7 +195,6 @@ export default class UserService { } await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]); - const updated = await this.userRepository.update(id, data); /** * Update user role @@ -209,6 +208,8 @@ export default class UserService { } } + const updated = await this.userRepository.update(id, data); + return { success: true, status: HttpStatusEnum.OK, From 4c0377d234527edf45c08e09c35c8131e0ff4e12 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Sun, 12 Jun 2022 19:55:13 +0300 Subject: [PATCH 7/9] feat: upload video lesson via src link --- app/Models/Lesson.ts | 3 ++ app/Repositories/LessonRepository.ts | 27 ++++++++++++------ app/Services/CourseService.ts | 28 ++++--------------- .../Lesson/CreateLessonValidator.ts | 3 +- .../Lesson/UpdateLessonValidator.ts | 1 + database/migrations/1628016919501_lessons.ts | 1 + 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/app/Models/Lesson.ts b/app/Models/Lesson.ts index 973d17a..94ff146 100644 --- a/app/Models/Lesson.ts +++ b/app/Models/Lesson.ts @@ -37,6 +37,9 @@ export default class Lesson extends BaseModel { @column() public description: string; + @column() + public linked_video_url: string | null; + @column() public duration: string; diff --git a/app/Repositories/LessonRepository.ts b/app/Repositories/LessonRepository.ts index 4875996..275b2b6 100644 --- a/app/Repositories/LessonRepository.ts +++ b/app/Repositories/LessonRepository.ts @@ -106,6 +106,7 @@ export default class LessonRepository { duration: data.duration.toFormat('HH:mm:ss'), display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1, course_id: course.id, + linked_video_url: !data.video ? data.linked_video_url : null, }); /** @@ -116,7 +117,9 @@ export default class LessonRepository { /** * Move video file to disk and save to database */ - await this.createVideo(lesson, data.video); + if (data.video) { + await this.createVideo(lesson, data.video); + } /** * Create materials @@ -154,22 +157,28 @@ export default class LessonRepository { }); /** - * Update video + * Update linked video url */ - if (data.video) { - /** - * Delete video from drive and database - */ + if (data.linked_video_url && !data.video) { if (lesson.video) { await this.Drive.delete(`videos/${lesson.video.name}`); } await lesson.related('video').query().delete(); + lesson.merge({ linked_video_url: data.linked_video_url }); + } - /** - * Upload new video to drive and save to database - */ + /** + * Update file video + */ + if (data.video) { + if (lesson.video) { + await this.Drive.delete(`videos/${lesson.video.name}`); + } + + await lesson.related('video').query().delete(); await this.createVideo(lesson, data.video); + lesson.merge({ linked_video_url: null }); } /** diff --git a/app/Services/CourseService.ts b/app/Services/CourseService.ts index 8a569b4..66b1f86 100644 --- a/app/Services/CourseService.ts +++ b/app/Services/CourseService.ts @@ -312,23 +312,7 @@ export default class CourseService { }; } - /** - * Collect all file names from lessons in course and delete files - */ - const videos: string[] = course.lessons.map(lesson => lesson.video.name); - const materials: string[] = course.lessons.map(lesson => lesson.materials.map(m => m.name)).flat(); - - await Promise.all( - videos.map(async name => { - await Drive.delete(`videos/${name}`); - }) - ); - - await Promise.all( - materials.map(async name => { - await Drive.delete(`materials/${name}`); - }) - ); + await this.deleteAllFiles([course]); return { success: true, @@ -377,13 +361,13 @@ export default class CourseService { await teacher.load('roles'); - const isTeacher = RoleHelper.userContainRoles(teacher.roles, [RoleEnum.TEACHER]); + const isTeacherOrAdmin = RoleHelper.userContainRoles(teacher.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]); - if (!isTeacher) { + if (!isTeacherOrAdmin) { return { success: false, status: HttpStatusEnum.BAD_REQUEST, - message: 'Author is not a teacher.', + message: 'Author is not a teacher or admin.', data: {}, error: { code: 'E_BAD_REQUEST', @@ -392,13 +376,13 @@ export default class CourseService { } } - await this.courseRepository.update(id, data); + const updated = await this.courseRepository.update(id, data); return { success: true, status: HttpStatusEnum.OK, message: 'Course updated.', - data: course, + data: updated || {}, }; } diff --git a/app/Validators/Lesson/CreateLessonValidator.ts b/app/Validators/Lesson/CreateLessonValidator.ts index 976de89..ca92e7d 100644 --- a/app/Validators/Lesson/CreateLessonValidator.ts +++ b/app/Validators/Lesson/CreateLessonValidator.ts @@ -9,10 +9,11 @@ export default class CreateLessonValidator { title: schema.string({}, [rules.maxLength(255)]), description: schema.string(), duration: schema.date({ format: 'HH:mm:ss' }), - video: schema.file({ + video: schema.file.optional({ size: '5000mb', extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'], }), + linked_video_url: schema.string.optional([rules.regex(new RegExp('^(http|https|ftp)://'))]), materials: schema.array.optional().members( schema.file({ size: '100mb', diff --git a/app/Validators/Lesson/UpdateLessonValidator.ts b/app/Validators/Lesson/UpdateLessonValidator.ts index 909fc7d..4f5b03d 100644 --- a/app/Validators/Lesson/UpdateLessonValidator.ts +++ b/app/Validators/Lesson/UpdateLessonValidator.ts @@ -13,6 +13,7 @@ export default class UpdateLessonValidator { size: '5000mb', extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'], }), + linked_video_url: schema.string.optional([rules.regex(new RegExp('^(http|https|ftp)://'))]), materials: schema.array.nullableAndOptional().members( schema.file({ size: '100mb', diff --git a/database/migrations/1628016919501_lessons.ts b/database/migrations/1628016919501_lessons.ts index 6c65853..e3fb51f 100644 --- a/database/migrations/1628016919501_lessons.ts +++ b/database/migrations/1628016919501_lessons.ts @@ -11,6 +11,7 @@ export default class Lessons extends BaseSchema { table.integer('display_order').notNullable(); table.integer('color_id').unsigned().references('colors.id'); table.string('description'); + table.text('linked_video_url').nullable(); table.time('duration').notNullable(); table.timestamp('created_at', { useTz: true }); table.timestamp('updated_at', { useTz: true }); From 066caa6a9da30188fe4e133760df465813db9b72 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Sun, 12 Jun 2022 20:17:39 +0300 Subject: [PATCH 8/9] fix: insert display order in lessons seeder --- database/seeders/Course.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/database/seeders/Course.ts b/database/seeders/Course.ts index 78ca18d..f27b1df 100644 --- a/database/seeders/Course.ts +++ b/database/seeders/Course.ts @@ -7,7 +7,13 @@ export default class CourseSeeder extends BaseSeeder { public async run() { this.CourseFactory = CourseFactory; - await this.CourseFactory.with('lessons', 10, lessonFactory => lessonFactory.with('content')) + const LESSONS_COUNT = 10; + + await this.CourseFactory.with('lessons', LESSONS_COUNT, lessonFactory => + lessonFactory + .with('content') + .merge(new Array(LESSONS_COUNT).fill(LESSONS_COUNT).map((_, i) => ({ display_order: i + 1 }))) + ) .with('category') .with('teacher') .createMany(3); From b9baca7f7f4b87c3fadbdc432bfa942a45b86fbd Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Sun, 12 Jun 2022 20:35:33 +0300 Subject: [PATCH 9/9] changelog & bump version --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d895bd..39437e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.4.2] - 2022-12-06 + +## Added + +- Uploading video from url source (YouTube, Vimeo, .mp4, etc..) +- Added student and teacher to seeders + +## Fix + +- Removing lesson files from drive when deleting course, category or user. +- Fixed lesson display ordering when seeding database +- Refactored bouncer actions + ## [1.3.2] - 2022-06-06 ## Added diff --git a/package.json b/package.json index d569578..565b343 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "1.3.2", + "version": "1.4.2", "private": true, "scripts": { "build": "node ace build --production",