mirror of
https://github.com/sergeyyarkov/educt-server.git
synced 2026-10-11 10:19:20 +03:00
Compare commits
No commits in common. "develop" and "v1.3.2" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,4 +6,3 @@ coverage
|
|||||||
.env
|
.env
|
||||||
tmp
|
tmp
|
||||||
uploads
|
uploads
|
||||||
npm-debug.log
|
|
||||||
13
CHANGELOG.md
13
CHANGELOG.md
@ -1,18 +1,5 @@
|
|||||||
# Changelog
|
# 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
|
## [1.3.2] - 2022-06-06
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export default class UsersController extends BaseController {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
public async delete(ctx: HttpContextContract) {
|
public async delete(ctx: HttpContextContract) {
|
||||||
const result = await this.userService.deleteUser(ctx.params.id, ctx);
|
const result = await this.userService.deleteUser(ctx.params.id);
|
||||||
|
|
||||||
if (!result.success && result.error) {
|
if (!result.success && result.error) {
|
||||||
throw new Exception(result.message, result.status, result.error.code);
|
throw new Exception(result.message, result.status, result.error.code);
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
import Course from 'App/Models/Course';
|
|
||||||
|
|
||||||
export type FileEntry = {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class CourseHelper {
|
|
||||||
public static getVideoFileNames(course: Course): Array<FileEntry | null> {
|
|
||||||
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.flatMap(l => l.materials.map(m => ({ path: 'materials/', name: m.name })));
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -37,9 +37,6 @@ export default class Lesson extends BaseModel {
|
|||||||
@column()
|
@column()
|
||||||
public description: string;
|
public description: string;
|
||||||
|
|
||||||
@column()
|
|
||||||
public linked_video_url: string | null;
|
|
||||||
|
|
||||||
@column()
|
@column()
|
||||||
public duration: string;
|
public duration: string;
|
||||||
|
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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<RoleEnum>) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -32,32 +32,6 @@ export default class CourseRepository {
|
|||||||
this.Course = Course;
|
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<Course[]> {
|
|
||||||
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<Course[]> {
|
|
||||||
const data = await this.Course.query()
|
|
||||||
.preload('lessons', q => q.preload('video').preload('materials'))
|
|
||||||
.where('category_id', id);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set status on course
|
* Set status on course
|
||||||
*
|
*
|
||||||
|
|||||||
@ -106,7 +106,6 @@ export default class LessonRepository {
|
|||||||
duration: data.duration.toFormat('HH:mm:ss'),
|
duration: data.duration.toFormat('HH:mm:ss'),
|
||||||
display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1,
|
display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1,
|
||||||
course_id: course.id,
|
course_id: course.id,
|
||||||
linked_video_url: !data.video ? data.linked_video_url : null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -117,9 +116,7 @@ export default class LessonRepository {
|
|||||||
/**
|
/**
|
||||||
* Move video file to disk and save to database
|
* Move video file to disk and save to database
|
||||||
*/
|
*/
|
||||||
if (data.video) {
|
await this.createVideo(lesson, data.video);
|
||||||
await this.createVideo(lesson, data.video);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create materials
|
* Create materials
|
||||||
@ -157,28 +154,22 @@ export default class LessonRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update linked video url
|
* Update video
|
||||||
*/
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update file video
|
|
||||||
*/
|
*/
|
||||||
if (data.video) {
|
if (data.video) {
|
||||||
|
/**
|
||||||
|
* Delete video from drive and database
|
||||||
|
*/
|
||||||
if (lesson.video) {
|
if (lesson.video) {
|
||||||
await this.Drive.delete(`videos/${lesson.video.name}`);
|
await this.Drive.delete(`videos/${lesson.video.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await lesson.related('video').query().delete();
|
await lesson.related('video').query().delete();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload new video to drive and save to database
|
||||||
|
*/
|
||||||
await this.createVideo(lesson, data.video);
|
await this.createVideo(lesson, data.video);
|
||||||
lesson.merge({ linked_video_url: null });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -211,10 +202,7 @@ export default class LessonRepository {
|
|||||||
/**
|
/**
|
||||||
* Delete files from disk
|
* Delete files from disk
|
||||||
*/
|
*/
|
||||||
if (lesson.video) {
|
await this.Drive.delete(`videos/${lesson.video.name}`);
|
||||||
await this.Drive.delete(`videos/${lesson.video.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.deleteMaterials(lesson);
|
await this.deleteMaterials(lesson);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
|
|||||||
* Repositories
|
* Repositories
|
||||||
*/
|
*/
|
||||||
import CategoryRepository from 'App/Repositories/CategoryRepository';
|
import CategoryRepository from 'App/Repositories/CategoryRepository';
|
||||||
import CourseRepository from 'App/Repositories/CourseRepository';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validators
|
* Validators
|
||||||
@ -18,27 +17,12 @@ import CourseRepository from 'App/Repositories/CourseRepository';
|
|||||||
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
|
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
|
||||||
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
|
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
|
||||||
|
|
||||||
/**
|
|
||||||
* Services
|
|
||||||
*/
|
|
||||||
import CourseService from 'App/Services/CourseService';
|
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export default class CategoryService {
|
export default class CategoryService {
|
||||||
private categoryRepository: CategoryRepository;
|
private categoryRepository: CategoryRepository;
|
||||||
|
|
||||||
private courseRepository: CourseRepository;
|
constructor(categoryRepository: CategoryRepository) {
|
||||||
|
|
||||||
private courseService: CourseService;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
categoryRepository: CategoryRepository,
|
|
||||||
courseRepository: CourseRepository,
|
|
||||||
courseService: CourseService
|
|
||||||
) {
|
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.courseRepository = courseRepository;
|
|
||||||
this.courseService = courseService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -110,7 +94,7 @@ export default class CategoryService {
|
|||||||
* @returns Deleted category
|
* @returns Deleted category
|
||||||
*/
|
*/
|
||||||
public async deleteCategory(id: string | number): Promise<IResponse> {
|
public async deleteCategory(id: string | number): Promise<IResponse> {
|
||||||
const data = await this.categoryRepository.getById(id);
|
const data = await this.categoryRepository.delete(id);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return {
|
return {
|
||||||
@ -124,10 +108,6 @@ export default class CategoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const courses = await this.courseRepository.getByCategoryId(id);
|
|
||||||
await this.courseService.deleteAllFiles(courses);
|
|
||||||
await this.categoryRepository.delete(id);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: HttpStatusEnum.OK,
|
status: HttpStatusEnum.OK,
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import UpdateCourseValidator from 'App/Validators/Course/UpdateCourseValidator';
|
|||||||
import FetchCoursesValidator from 'App/Validators/Course/FetchCoursesValidator';
|
import FetchCoursesValidator from 'App/Validators/Course/FetchCoursesValidator';
|
||||||
import Course from 'App/Models/Course';
|
import Course from 'App/Models/Course';
|
||||||
import Drive from '@ioc:Adonis/Core/Drive';
|
import Drive from '@ioc:Adonis/Core/Drive';
|
||||||
import CourseHelper, { FileEntry } from 'App/Helpers/CourseHelper';
|
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export default class CourseService {
|
export default class CourseService {
|
||||||
@ -235,9 +234,9 @@ export default class CourseService {
|
|||||||
/**
|
/**
|
||||||
* Find course teacher
|
* Find course teacher
|
||||||
*/
|
*/
|
||||||
const user = await this.userRepository.getById(data.teacher_id);
|
const teacher = await this.userRepository.getById(data.teacher_id);
|
||||||
|
|
||||||
if (!user) {
|
if (!teacher) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status: HttpStatusEnum.NOT_FOUND,
|
status: HttpStatusEnum.NOT_FOUND,
|
||||||
@ -249,13 +248,13 @@ export default class CourseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHasPermissions = RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]);
|
const isTeacher = RoleHelper.userHasRoles(teacher.roles, [RoleEnum.TEACHER]);
|
||||||
|
|
||||||
if (!isHasPermissions) {
|
if (!isTeacher) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status: HttpStatusEnum.BAD_REQUEST,
|
status: HttpStatusEnum.BAD_REQUEST,
|
||||||
message: `User with id "${user.id}" does not have sufficient permissions.`,
|
message: `User with id "${teacher.id}" not a teacher.`,
|
||||||
data: {},
|
data: {},
|
||||||
error: {
|
error: {
|
||||||
code: 'E_BAD_REQUEST',
|
code: 'E_BAD_REQUEST',
|
||||||
@ -312,7 +311,23 @@ export default class CourseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.deleteAllFiles([course]);
|
/**
|
||||||
|
* 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}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -330,7 +345,7 @@ export default class CourseService {
|
|||||||
* @returns Response
|
* @returns Response
|
||||||
*/
|
*/
|
||||||
public async updateCourse(id: string | number, data: UpdateCourseValidator['schema']['props']): Promise<IResponse> {
|
public async updateCourse(id: string | number, data: UpdateCourseValidator['schema']['props']): Promise<IResponse> {
|
||||||
const course = await this.courseRepository.getById(id);
|
const course = await this.courseRepository.update(id, data);
|
||||||
|
|
||||||
if (!course) {
|
if (!course) {
|
||||||
return {
|
return {
|
||||||
@ -344,45 +359,11 @@ export default class CourseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 isTeacherOrAdmin = RoleHelper.userContainRoles(teacher.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]);
|
|
||||||
|
|
||||||
if (!isTeacherOrAdmin) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: HttpStatusEnum.BAD_REQUEST,
|
|
||||||
message: 'Author is not a teacher or admin.',
|
|
||||||
data: {},
|
|
||||||
error: {
|
|
||||||
code: 'E_BAD_REQUEST',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.courseRepository.update(id, data);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: HttpStatusEnum.OK,
|
status: HttpStatusEnum.OK,
|
||||||
message: 'Course updated.',
|
message: 'Course updated.',
|
||||||
data: updated || {},
|
data: course,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -705,29 +686,6 @@ export default class CourseService {
|
|||||||
data: { count },
|
data: { count },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This function will delete all associated course files on disk
|
|
||||||
*
|
|
||||||
* @param courses List of courses
|
|
||||||
*/
|
|
||||||
public async deleteAllFiles(courses: Course[]): Promise<void> {
|
|
||||||
/**
|
|
||||||
* Collect file names
|
|
||||||
*/
|
|
||||||
const images = courses.map(course => CourseHelper.getImageFileName(course)).filter(Boolean) as FileEntry[];
|
|
||||||
const videos = courses.flatMap(course => CourseHelper.getVideoFileNames(course)).filter(Boolean) as FileEntry[];
|
|
||||||
const materials = courses.flatMap(course => CourseHelper.getMaterialFileNames(course));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
new Ioc().make(CourseService);
|
||||||
|
|||||||
@ -78,7 +78,20 @@ export default class LessonService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.bouncer.with('LessonPolicy').authorize('view', lesson);
|
/**
|
||||||
|
* 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load lesson with materials
|
* Load lesson with materials
|
||||||
@ -96,8 +109,7 @@ export default class LessonService {
|
|||||||
/**
|
/**
|
||||||
* Fetch lesson material by file name
|
* Fetch lesson material by file name
|
||||||
*
|
*
|
||||||
* @param ctx Http context
|
* @param fileName Name of file
|
||||||
* @param name Filename
|
|
||||||
* @returns Response
|
* @returns Response
|
||||||
*/
|
*/
|
||||||
public async fetchMaterialFile(ctx: HttpContextContract, name: string): Promise<IResponse<LessonMaterial>> {
|
public async fetchMaterialFile(ctx: HttpContextContract, name: string): Promise<IResponse<LessonMaterial>> {
|
||||||
@ -119,7 +131,18 @@ export default class LessonService {
|
|||||||
* Allow user to view lesson material
|
* Allow user to view lesson material
|
||||||
*/
|
*/
|
||||||
await material.load('lesson');
|
await material.load('lesson');
|
||||||
await ctx.bouncer.with('LessonPolicy').authorize('view', material.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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -145,7 +168,18 @@ export default class LessonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await video.load('lesson');
|
await video.load('lesson');
|
||||||
await ctx.bouncer.with('LessonPolicy').authorize('view', video.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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -177,7 +211,17 @@ export default class LessonService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.bouncer.with('LessonPolicy').authorize('view', lesson);
|
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const progress = await this.lessonProgressRepository.get(user.id, lesson.id);
|
const progress = await this.lessonProgressRepository.get(user.id, lesson.id);
|
||||||
|
|
||||||
@ -310,7 +354,20 @@ export default class LessonService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.bouncer.with('LessonPolicy').authorize('view', lesson);
|
/**
|
||||||
|
* 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load lesson with materials
|
* Load lesson with materials
|
||||||
|
|||||||
@ -22,48 +22,21 @@ import Role from 'App/Models/Role';
|
|||||||
import RoleRepository from 'App/Repositories/RoleRepository';
|
import RoleRepository from 'App/Repositories/RoleRepository';
|
||||||
import UserRepository from 'App/Repositories/UserRepository';
|
import UserRepository from 'App/Repositories/UserRepository';
|
||||||
|
|
||||||
/**
|
|
||||||
* Services
|
|
||||||
*/
|
|
||||||
import CourseService from './CourseService';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validators
|
* Validators
|
||||||
*/
|
*/
|
||||||
import CreateUserValidator from 'App/Validators/User/CreateUserValidator';
|
import CreateUserValidator from 'App/Validators/User/CreateUserValidator';
|
||||||
import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator';
|
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()
|
@inject()
|
||||||
export default class UserService {
|
export default class UserService {
|
||||||
private userRepository: UserRepository;
|
private userRepository: UserRepository;
|
||||||
|
|
||||||
private roleRepository: RoleRepository;
|
private roleRepository: RoleRepository;
|
||||||
|
|
||||||
private courseRepository: CourseRepository;
|
constructor(userRepository: UserRepository, roleRepository: RoleRepository) {
|
||||||
|
|
||||||
private courseService: CourseService;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
userRepository: UserRepository,
|
|
||||||
roleRepository: RoleRepository,
|
|
||||||
courseRepository: CourseRepository,
|
|
||||||
courseService: CourseService
|
|
||||||
) {
|
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.roleRepository = roleRepository;
|
this.roleRepository = roleRepository;
|
||||||
this.courseRepository = courseRepository;
|
|
||||||
this.courseService = courseService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -147,7 +120,20 @@ export default class UserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.bouncer.with('RolePolicy').authorize('manage', [role.slug as RoleEnum]);
|
/**
|
||||||
|
* 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new user
|
* Create new user
|
||||||
@ -180,7 +166,7 @@ export default class UserService {
|
|||||||
data: UpdateUserValidator['schema']['props'],
|
data: UpdateUserValidator['schema']['props'],
|
||||||
ctx: HttpContextContract
|
ctx: HttpContextContract
|
||||||
): Promise<IResponse> {
|
): Promise<IResponse> {
|
||||||
const user = await this.userRepository.getById(id);
|
const user = await this.userRepository.update(id, data);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@ -194,8 +180,6 @@ export default class UserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update user role
|
* Update user role
|
||||||
*/
|
*/
|
||||||
@ -203,18 +187,27 @@ export default class UserService {
|
|||||||
const role = await this.roleRepository.getBySlug(data.role);
|
const role = await this.roleRepository.getBySlug(data.role);
|
||||||
|
|
||||||
if (role) {
|
if (role) {
|
||||||
await ctx.bouncer.with('RolePolicy').authorize('manage', [data.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 this.userRepository.updateRoles(user, [role]);
|
await this.userRepository.updateRoles(user, [role]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.userRepository.update(id, data);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: HttpStatusEnum.OK,
|
status: HttpStatusEnum.OK,
|
||||||
message: 'User updated.',
|
message: 'User updated.',
|
||||||
data: updated || {},
|
data: user,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,8 +217,8 @@ export default class UserService {
|
|||||||
* @param id User id
|
* @param id User id
|
||||||
* @returns Response
|
* @returns Response
|
||||||
*/
|
*/
|
||||||
public async deleteUser(id: string | number, ctx: HttpContextContract): Promise<IResponse> {
|
public async deleteUser(id: string | number): Promise<IResponse> {
|
||||||
const user = await this.userRepository.getById(id);
|
const user = await this.userRepository.delete(id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@ -239,15 +232,6 @@ 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: HttpStatusEnum.OK,
|
status: HttpStatusEnum.OK,
|
||||||
|
|||||||
@ -9,11 +9,10 @@ export default class CreateLessonValidator {
|
|||||||
title: schema.string({}, [rules.maxLength(255)]),
|
title: schema.string({}, [rules.maxLength(255)]),
|
||||||
description: schema.string(),
|
description: schema.string(),
|
||||||
duration: schema.date({ format: 'HH:mm:ss' }),
|
duration: schema.date({ format: 'HH:mm:ss' }),
|
||||||
video: schema.file.optional({
|
video: schema.file({
|
||||||
size: '5000mb',
|
size: '5000mb',
|
||||||
extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'],
|
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(
|
materials: schema.array.optional().members(
|
||||||
schema.file({
|
schema.file({
|
||||||
size: '100mb',
|
size: '100mb',
|
||||||
|
|||||||
@ -13,7 +13,6 @@ export default class UpdateLessonValidator {
|
|||||||
size: '5000mb',
|
size: '5000mb',
|
||||||
extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'],
|
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(
|
materials: schema.array.nullableAndOptional().members(
|
||||||
schema.file({
|
schema.file({
|
||||||
size: '100mb',
|
size: '100mb',
|
||||||
|
|||||||
@ -11,7 +11,6 @@ export default class Lessons extends BaseSchema {
|
|||||||
table.integer('display_order').notNullable();
|
table.integer('display_order').notNullable();
|
||||||
table.integer('color_id').unsigned().references('colors.id');
|
table.integer('color_id').unsigned().references('colors.id');
|
||||||
table.string('description');
|
table.string('description');
|
||||||
table.text('linked_video_url').nullable();
|
|
||||||
table.time('duration').notNullable();
|
table.time('duration').notNullable();
|
||||||
table.timestamp('created_at', { useTz: true });
|
table.timestamp('created_at', { useTz: true });
|
||||||
table.timestamp('updated_at', { useTz: true });
|
table.timestamp('updated_at', { useTz: true });
|
||||||
|
|||||||
@ -7,13 +7,7 @@ export default class CourseSeeder extends BaseSeeder {
|
|||||||
public async run() {
|
public async run() {
|
||||||
this.CourseFactory = CourseFactory;
|
this.CourseFactory = CourseFactory;
|
||||||
|
|
||||||
const LESSONS_COUNT = 10;
|
await this.CourseFactory.with('lessons', 10, lessonFactory => lessonFactory.with('content'))
|
||||||
|
|
||||||
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('category')
|
||||||
.with('teacher')
|
.with('teacher')
|
||||||
.createMany(3);
|
.createMany(3);
|
||||||
|
|||||||
@ -11,48 +11,23 @@ export default class UserSeeder extends BaseSeeder {
|
|||||||
*/
|
*/
|
||||||
const roles = {
|
const roles = {
|
||||||
admin: await Role.findByOrFail('slug', 'admin'),
|
admin: await Role.findByOrFail('slug', 'admin'),
|
||||||
teacher: await Role.findByOrFail('slug', 'teacher'),
|
|
||||||
student: await Role.findByOrFail('slug', 'student'),
|
student: await Role.findByOrFail('slug', 'student'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Administrator
|
* Administrator
|
||||||
*/
|
*/
|
||||||
const administrator = await User.create({
|
const userAdmin = await User.create({
|
||||||
first_name: 'John',
|
first_name: 'John',
|
||||||
last_name: 'Doe',
|
last_name: 'Doe',
|
||||||
login: 'admin',
|
login: 'admin',
|
||||||
password: '123456',
|
password: '123456',
|
||||||
email: 'administrator@example.com',
|
email: 'administrator@example.com',
|
||||||
});
|
});
|
||||||
await administrator.related('roles').attach([roles.admin.id]);
|
await userAdmin.related('roles').attach([roles.admin.id]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teacher
|
* Students
|
||||||
*/
|
|
||||||
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);
|
await StudentFactory.with('contacts').createMany(20);
|
||||||
}
|
}
|
||||||
|
|||||||
1
env.ts
1
env.ts
@ -15,6 +15,7 @@
|
|||||||
import Env from '@ioc:Adonis/Core/Env';
|
import Env from '@ioc:Adonis/Core/Env';
|
||||||
|
|
||||||
export default Env.rules({
|
export default Env.rules({
|
||||||
|
HOST: Env.schema.string({ format: 'host' }),
|
||||||
PORT: Env.schema.number(),
|
PORT: Env.schema.number(),
|
||||||
APP_KEY: Env.schema.string(),
|
APP_KEY: Env.schema.string(),
|
||||||
APP_NAME: Env.schema.string(),
|
APP_NAME: Env.schema.string(),
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "1.4.2",
|
"version": "1.3.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "1.4.2",
|
"version": "1.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adonisjs/application": "^5.2.5",
|
"@adonisjs/application": "^5.2.5",
|
||||||
"@adonisjs/attachment-lite": "^1.0.7",
|
"@adonisjs/attachment-lite": "^1.0.7",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "1.4.5",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ace build --production",
|
"build": "node ace build --production",
|
||||||
|
|||||||
@ -6,6 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Bouncer from '@ioc:Adonis/Addons/Bouncer';
|
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';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@ -29,7 +34,22 @@ import Bouncer from '@ioc:Adonis/Addons/Bouncer';
|
|||||||
| NOTE: Always export the "actions" const from this file
|
| NOTE: Always export the "actions" const from this file
|
||||||
|****************************************************************
|
|****************************************************************
|
||||||
*/
|
*/
|
||||||
export const { actions } = Bouncer;
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@ -54,7 +74,4 @@ export const { actions } = Bouncer;
|
|||||||
| NOTE: Always export the "policies" const from this file
|
| 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'),
|
|
||||||
});
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ Route.group(() => {
|
|||||||
Route.patch('/:id', 'Api/v1/UsersController.update').middleware('role:admin,teacher').as('users.update');
|
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.delete('/:id', 'Api/v1/UsersController.delete').middleware('role:admin,teacher').as('users.delete');
|
||||||
Route.post('/:id/attach-roles', 'Api/v1/UsersController.attachRoles')
|
Route.post('/:id/attach-roles', 'Api/v1/UsersController.attachRoles')
|
||||||
.middleware('role:admin')
|
.middleware('role:admin,teacher')
|
||||||
.as('users.attach-role');
|
.as('users.attach-role');
|
||||||
Route.delete('/:id/detach-roles', 'Api/v1/UsersController.detachRoles')
|
Route.delete('/:id/detach-roles', 'Api/v1/UsersController.detachRoles')
|
||||||
.middleware('role:admin')
|
.middleware('role:admin')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user