Compare commits

...

40 Commits

Author SHA1 Message Date
2e90ad2524 Merge tag 'v1.4.5' into develop
Release v1.4.5
2022-06-13 14:09:19 +03:00
135b9faa6b Merge branch 'release/1.4.5' 2022-06-13 14:09:13 +03:00
1b13dd6ce1 bump v 2022-06-13 14:08:58 +03:00
6fe7b9873b disable ssl for db 2022-06-13 14:08:27 +03:00
6091987ba4 Merge tag 'v1.4.4' into develop
Release v1.4.4
2022-06-13 14:04:31 +03:00
fa8270f88e Merge branch 'release/1.4.4' 2022-06-13 14:04:22 +03:00
6b26489f9e bump ver 2022-06-13 14:04:06 +03:00
933c9aa887 del procfile 2022-06-13 14:02:54 +03:00
Sergey Yarkov
7b55d47360
Merge pull request #1 from sergeyyarkov/develop
Merge tag 'v1.4.3' into develop
2022-06-13 13:45:42 +03:00
7ec72a53b6 trust proxy 2022-06-13 13:17:25 +03:00
a3ce94f1de trust proxy 2022-06-13 13:14:40 +03:00
bfd41f391a db ssl 2022-06-13 01:33:41 +03:00
da67d3d4e1 Merge tag 'v1.4.3' into develop
Release v1.4.3
2022-06-13 01:25:24 +03:00
ef9f757c35 Merge branch 'release/1.4.3' 2022-06-13 01:25:15 +03:00
8f9d6e8cab bump version 2022-06-13 01:24:59 +03:00
9134c4d912 Procfile 2022-06-13 01:12:32 +03:00
7bc2db4451 removed host env 2022-06-13 01:02:21 +03:00
8508a6c43e del 2022-06-12 22:11:17 +03:00
5510d9fbaf make it better 2022-06-12 22:05:55 +03:00
92065a2ff5 Merge tag 'v1.4.2' into develop
Release v1.4.2
2022-06-12 20:36:05 +03:00
b088442d47 Merge branch 'release/1.4.2' 2022-06-12 20:35:43 +03:00
b9baca7f7f changelog & bump version 2022-06-12 20:35:33 +03:00
066caa6a9d fix: insert display order in lessons seeder 2022-06-12 20:17:39 +03:00
4c0377d234 feat: upload video lesson via src link 2022-06-12 19:55:13 +03:00
cc36a77912 fix: user upd 2022-06-10 00:36:41 +03:00
0472036b66 refactor: refactored course service 2022-06-10 00:28:11 +03:00
7149a0f3eb fix: remove course files from drive after deleting user or category 2022-06-09 23:50:40 +03:00
30fb10b338 feat: added new users to seeder 2022-06-09 19:02:35 +03:00
b6217a4007 refactor: moved bouncer actions to policies 2022-06-08 23:08:09 +03:00
06e8acd0b2 fix: lesson deleting 2022-06-07 00:51:03 +03:00
4aed828c4e Merge tag 'v1.3.2' into develop
Version 1.3.2
2022-06-07 00:14:15 +03:00
8a8e3cd552 Merge branch 'release/1.3.2' 2022-06-07 00:13:54 +03:00
3f5222eb88 bump version 2022-06-07 00:13:37 +03:00
ff97175fbe fix: fixed ts types 2022-06-06 23:24:25 +03:00
303a27089b fix: remove all lesson file on delete course 2022-06-06 22:47:42 +03:00
22ff69a3b4 changelog 2022-06-06 19:29:02 +03:00
294a4f584d feat: added mailer env 2022-06-05 23:04:15 +03:00
5171f65429 fix: cors & api path 2022-06-04 00:31:46 +03:00
ffdebc4f92 fix: lesson duration field updating 2022-06-02 22:44:12 +03:00
5a01acdb21 Merge tag 'v1.3.1' into develop
Release v1.3.1
2022-04-10 23:23:55 +03:00
52 changed files with 14017 additions and 3035 deletions

View File

@ -15,7 +15,7 @@
"project": "./tsconfig.json", "project": "./tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.js", "test/**/*.ts"] "include": ["src/**/*.ts", "src/**/*.js", "test/**/*.ts"]
}, },
"plugins": ["@typescript-eslint"], "plugins": ["@typescript-eslint", "import"],
"rules": { "rules": {
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"] "@typescript-eslint/no-unused-vars": ["error"]

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ coverage
.env .env
tmp tmp
uploads uploads
npm-debug.log

View File

@ -1,5 +1,32 @@
# 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
## Added
- Updated npm dependencies
- [Added](https://github.com/sergeyyarkov/educt-server/commit/294a4f584dd39d295372802305004365fb01fdb2) `MAILER` env variable
## Fix
- Fixed schema validator on saving lesson order
- Fixed lesson [duration updating](https://github.com/sergeyyarkov/educt-server/commit/ffdebc4f927abe3b63c62c84268f40f9e6663895#diff-86d18314585862595621b4e90f675b79ac74e7a0a47b2797556902eeff75e14a)
- Updated cors config
- Deleted `api` from path url
## [1.3.1] - 2022-04-10 ## [1.3.1] - 2022-04-10
## Added ## Added

View File

@ -11,19 +11,66 @@
}, },
"list:routes": { "list:routes": {
"settings": { "settings": {
"loadApp": true "loadApp": true,
"stayAlive": true
}, },
"commandPath": "@adonisjs/core/build/commands/ListRoutes", "commandPath": "@adonisjs/core/build/commands/ListRoutes/index",
"commandName": "list:routes", "commandName": "list:routes",
"description": "List application routes", "description": "List application routes",
"args": [], "args": [],
"aliases": [], "aliases": [],
"flags": [ "flags": [
{
"name": "verbose",
"propertyName": "verbose",
"type": "boolean",
"description": "Display more information"
},
{
"name": "reverse",
"propertyName": "reverse",
"type": "boolean",
"alias": "r",
"description": "Reverse routes display"
},
{
"name": "methods",
"propertyName": "methodsFilter",
"type": "array",
"alias": "m",
"description": "Filter routes by method"
},
{
"name": "patterns",
"propertyName": "patternsFilter",
"type": "array",
"alias": "p",
"description": "Filter routes by the route pattern"
},
{
"name": "names",
"propertyName": "namesFilter",
"type": "array",
"alias": "n",
"description": "Filter routes by route name"
},
{ {
"name": "json", "name": "json",
"propertyName": "json", "propertyName": "json",
"type": "boolean", "type": "boolean",
"description": "Output as JSON" "description": "Output as JSON"
},
{
"name": "table",
"propertyName": "table",
"type": "boolean",
"description": "Output as Table"
},
{
"name": "max-width",
"propertyName": "maxWidth",
"type": "number",
"description": "Specify maximum rendering width. Ignored for JSON Output"
} }
] ]
}, },
@ -55,7 +102,7 @@
}, },
"commandPath": "@adonisjs/lucid/build/commands/DbSeed", "commandPath": "@adonisjs/lucid/build/commands/DbSeed",
"commandName": "db:seed", "commandName": "db:seed",
"description": "Execute database seeder files", "description": "Execute database seeders",
"args": [], "args": [],
"aliases": [], "aliases": [],
"flags": [ "flags": [
@ -79,6 +126,12 @@
"type": "array", "type": "array",
"description": "Define a custom set of seeders files names to run", "description": "Define a custom set of seeders files names to run",
"alias": "f" "alias": "f"
},
{
"name": "compact-output",
"propertyName": "compactOutput",
"type": "boolean",
"description": "A compact single-line output"
} }
] ]
}, },
@ -113,6 +166,13 @@
"type": "boolean", "type": "boolean",
"alias": "c", "alias": "c",
"description": "Generate the controller for the model" "description": "Generate the controller for the model"
},
{
"name": "factory",
"propertyName": "factory",
"type": "boolean",
"alias": "f",
"description": "Generate a factory for the model"
} }
] ]
}, },
@ -177,13 +237,81 @@
"aliases": [], "aliases": [],
"flags": [] "flags": []
}, },
"make:factory": {
"settings": {},
"commandPath": "@adonisjs/lucid/build/commands/MakeFactory",
"commandName": "make:factory",
"description": "Make a new factory",
"args": [
{
"type": "string",
"propertyName": "model",
"name": "model",
"required": true,
"description": "The name of the model"
}
],
"aliases": [],
"flags": [
{
"name": "model-path",
"propertyName": "modelPath",
"type": "string",
"description": "The path to the model"
},
{
"name": "exact",
"propertyName": "exact",
"type": "boolean",
"description": "Create the factory with the exact name as provided",
"alias": "e"
}
]
},
"db:wipe": {
"settings": {
"loadApp": true
},
"commandPath": "@adonisjs/lucid/build/commands/DbWipe",
"commandName": "db:wipe",
"description": "Drop all tables, views and types in database",
"args": [],
"aliases": [],
"flags": [
{
"name": "connection",
"propertyName": "connection",
"type": "string",
"description": "Define a custom database connection",
"alias": "c"
},
{
"name": "drop-views",
"propertyName": "dropViews",
"type": "boolean",
"description": "Drop all views"
},
{
"name": "drop-types",
"propertyName": "dropTypes",
"type": "boolean",
"description": "Drop all custom types (Postgres only)"
},
{
"name": "force",
"propertyName": "force",
"type": "boolean",
"description": "Explicitly force command to run in production"
}
]
},
"migration:run": { "migration:run": {
"settings": { "settings": {
"loadApp": true "loadApp": true
}, },
"commandPath": "@adonisjs/lucid/build/commands/Migration/Run", "commandPath": "@adonisjs/lucid/build/commands/Migration/Run",
"commandName": "migration:run", "commandName": "migration:run",
"description": "Run pending migrations", "description": "Migrate database by running pending migrations",
"args": [], "args": [],
"aliases": [], "aliases": [],
"flags": [ "flags": [
@ -204,7 +332,13 @@
"name": "dry-run", "name": "dry-run",
"propertyName": "dryRun", "propertyName": "dryRun",
"type": "boolean", "type": "boolean",
"description": "Print SQL queries, instead of running the migrations" "description": "Do not run actual queries. Instead view the SQL output"
},
{
"name": "compact-output",
"propertyName": "compactOutput",
"type": "boolean",
"description": "A compact single-line output"
} }
] ]
}, },
@ -214,7 +348,7 @@
}, },
"commandPath": "@adonisjs/lucid/build/commands/Migration/Rollback", "commandPath": "@adonisjs/lucid/build/commands/Migration/Rollback",
"commandName": "migration:rollback", "commandName": "migration:rollback",
"description": "Rollback migrations to a given batch number", "description": "Rollback migrations to a specific batch number",
"args": [], "args": [],
"aliases": [], "aliases": [],
"flags": [ "flags": [
@ -235,13 +369,19 @@
"name": "dry-run", "name": "dry-run",
"propertyName": "dryRun", "propertyName": "dryRun",
"type": "boolean", "type": "boolean",
"description": "Print SQL queries, instead of running the migrations" "description": "Do not run actual queries. Instead view the SQL output"
}, },
{ {
"name": "batch", "name": "batch",
"propertyName": "batch", "propertyName": "batch",
"type": "number", "type": "number",
"description": "Define custom batch number for rollback. Use 0 to rollback to initial state" "description": "Define custom batch number for rollback. Use 0 to rollback to initial state"
},
{
"name": "compact-output",
"propertyName": "compactOutput",
"type": "boolean",
"description": "A compact single-line output"
} }
] ]
}, },
@ -251,7 +391,7 @@
}, },
"commandPath": "@adonisjs/lucid/build/commands/Migration/Status", "commandPath": "@adonisjs/lucid/build/commands/Migration/Status",
"commandName": "migration:status", "commandName": "migration:status",
"description": "Check migrations current status.", "description": "View migrations status",
"args": [], "args": [],
"aliases": [], "aliases": [],
"flags": [ "flags": [
@ -264,6 +404,117 @@
} }
] ]
}, },
"migration:reset": {
"settings": {
"loadApp": true
},
"commandPath": "@adonisjs/lucid/build/commands/Migration/Reset",
"commandName": "migration:reset",
"description": "Rollback all migrations",
"args": [],
"aliases": [],
"flags": [
{
"name": "connection",
"propertyName": "connection",
"type": "string",
"description": "Define a custom database connection",
"alias": "c"
},
{
"name": "force",
"propertyName": "force",
"type": "boolean",
"description": "Explicitly force command to run in production"
},
{
"name": "dry-run",
"propertyName": "dryRun",
"type": "boolean",
"description": "Do not run actual queries. Instead view the SQL output"
}
]
},
"migration:refresh": {
"settings": {
"loadApp": true
},
"commandPath": "@adonisjs/lucid/build/commands/Migration/Refresh",
"commandName": "migration:refresh",
"description": "Rollback and migrate database",
"args": [],
"aliases": [],
"flags": [
{
"name": "connection",
"propertyName": "connection",
"type": "string",
"description": "Define a custom database connection",
"alias": "c"
},
{
"name": "force",
"propertyName": "force",
"type": "boolean",
"description": "Explicitly force command to run in production"
},
{
"name": "dry-run",
"propertyName": "dryRun",
"type": "boolean",
"description": "Do not run actual queries. Instead view the SQL output"
},
{
"name": "seed",
"propertyName": "seed",
"type": "boolean",
"description": "Run seeders"
}
]
},
"migration:fresh": {
"settings": {
"loadApp": true
},
"commandPath": "@adonisjs/lucid/build/commands/Migration/Fresh",
"commandName": "migration:fresh",
"description": "Drop all tables and re-migrate the database",
"args": [],
"aliases": [],
"flags": [
{
"name": "connection",
"propertyName": "connection",
"type": "string",
"description": "Define a custom database connection",
"alias": "c"
},
{
"name": "force",
"propertyName": "force",
"type": "boolean",
"description": "Explicitly force command to run in production"
},
{
"name": "seed",
"propertyName": "seed",
"type": "boolean",
"description": "Run seeders"
},
{
"name": "drop-views",
"propertyName": "dropViews",
"type": "boolean",
"description": "Drop all views"
},
{
"name": "drop-types",
"propertyName": "dropTypes",
"type": "boolean",
"description": "Drop all custom types (Postgres only)"
}
]
},
"make:mailer": { "make:mailer": {
"settings": {}, "settings": {},
"commandPath": "@adonisjs/mail/build/commands/MakeMailer", "commandPath": "@adonisjs/mail/build/commands/MakeMailer",

View File

@ -1,6 +1,6 @@
import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone'; import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import { schema } from '@ioc:Adonis/Core/Validator'; import { schema, rules } from '@ioc:Adonis/Core/Validator';
/** /**
* Services * Services
@ -141,7 +141,7 @@ export default class LessonsController extends BaseController {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
public async saveOrder(ctx: HttpContextContract) { public async saveOrder(ctx: HttpContextContract) {
const saveOrderSchema = schema.create({ const saveOrderSchema = schema.create({
ids: schema.array().members(schema.string()), ids: schema.array([rules.minLength(1)]).members(schema.string()),
}); });
const payload = await ctx.request.validate({ schema: saveOrderSchema }); const payload = await ctx.request.validate({ schema: saveOrderSchema });
const { data, message, status, success, error } = await this.lessonService.updateOrder(payload.ids); const { data, message, status, success, error } = await this.lessonService.updateOrder(payload.ids);

View File

@ -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); const result = await this.userService.deleteUser(ctx.params.id, ctx);
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);

View File

@ -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<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;
}
}

View File

@ -37,6 +37,9 @@ 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;

View File

@ -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);
}
}

View File

@ -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<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;
}
}
}

View File

@ -32,6 +32,32 @@ 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
* *
@ -237,6 +263,7 @@ export default class CourseRepository {
public async delete(id: string | number): Promise<Course | null> { public async delete(id: string | number): Promise<Course | null> {
const course = await this.Course.query() const course = await this.Course.query()
.preload('category') .preload('category')
.preload('lessons', q => q.preload('video').preload('materials'))
.withCount('students') .withCount('students')
.withCount('likes') .withCount('likes')
.withCount('lessons') .withCount('lessons')

View File

@ -106,6 +106,7 @@ 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,
}); });
/** /**
@ -116,7 +117,9 @@ 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
@ -144,31 +147,38 @@ export default class LessonRepository {
if (lesson) { if (lesson) {
/** /**
* Update files * Update fields
*/ */
lesson.merge({ lesson.merge({
title: data.title, title: data.title,
description: data.description, description: data.description,
course_id: data.course_id, course_id: data.course_id,
...(data.duration && { duration: data.duration.toFormat('HH:mm:ss') }),
}); });
/** /**
* 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) { 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();
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); await this.createVideo(lesson, data.video);
lesson.merge({ linked_video_url: null });
} }
/** /**
@ -201,7 +211,10 @@ 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);
/** /**

View File

@ -72,7 +72,7 @@ export default class AuthService {
}); });
/** /**
* Set cookie toke to response * Set cookie token to response
*/ */
ctx.response.cookie('token', token.token); ctx.response.cookie('token', token.token);

View File

@ -10,6 +10,7 @@ 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
@ -17,12 +18,27 @@ import CategoryRepository from 'App/Repositories/CategoryRepository';
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;
constructor(categoryRepository: CategoryRepository) { private courseRepository: CourseRepository;
private courseService: CourseService;
constructor(
categoryRepository: CategoryRepository,
courseRepository: CourseRepository,
courseService: CourseService
) {
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.courseRepository = courseRepository;
this.courseService = courseService;
} }
/** /**
@ -94,7 +110,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.delete(id); const data = await this.categoryRepository.getById(id);
if (!data) { if (!data) {
return { 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 { return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,

View File

@ -25,6 +25,8 @@ import CreateCourseValidator from 'App/Validators/Course/CreateCourseValidator';
import UpdateCourseValidator from 'App/Validators/Course/UpdateCourseValidator'; 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 CourseHelper, { FileEntry } from 'App/Helpers/CourseHelper';
@inject() @inject()
export default class CourseService { export default class CourseService {
@ -233,9 +235,9 @@ export default class CourseService {
/** /**
* Find course teacher * 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 { return {
success: false, success: false,
status: HttpStatusEnum.NOT_FOUND, status: HttpStatusEnum.NOT_FOUND,
@ -247,13 +249,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 { return {
success: false, success: false,
status: HttpStatusEnum.BAD_REQUEST, 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: {}, data: {},
error: { error: {
code: 'E_BAD_REQUEST', code: 'E_BAD_REQUEST',
@ -310,6 +312,8 @@ export default class CourseService {
}; };
} }
await this.deleteAllFiles([course]);
return { return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,
@ -326,7 +330,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.update(id, data); const course = await this.courseRepository.getById(id);
if (!course) { if (!course) {
return { return {
@ -340,11 +344,45 @@ 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: course, data: updated || {},
}; };
} }
@ -667,6 +705,29 @@ 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);

View File

@ -78,20 +78,7 @@ 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
@ -109,7 +96,8 @@ export default class LessonService {
/** /**
* Fetch lesson material by file name * Fetch lesson material by file name
* *
* @param fileName Name of file * @param ctx Http context
* @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>> {
@ -131,18 +119,7 @@ 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,
@ -168,18 +145,7 @@ 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,
@ -211,17 +177,7 @@ export default class LessonService {
}; };
} }
if (await ctx.bouncer.denies('viewLessonContent', lesson)) { await ctx.bouncer.with('LessonPolicy').authorize('view', 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);
@ -354,20 +310,7 @@ 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

View File

@ -22,21 +22,48 @@ 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;
constructor(userRepository: UserRepository, roleRepository: RoleRepository) { private courseRepository: CourseRepository;
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;
} }
/** /**
@ -120,20 +147,7 @@ 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
@ -166,7 +180,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.update(id, data); const user = await this.userRepository.getById(id);
if (!user) { if (!user) {
return { return {
@ -180,6 +194,8 @@ 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
*/ */
@ -187,27 +203,18 @@ export default class UserService {
const role = await this.roleRepository.getBySlug(data.role); const role = await this.roleRepository.getBySlug(data.role);
if (role) { if (role) {
if (await ctx.bouncer.denies('manageUserRole', role)) { await ctx.bouncer.with('RolePolicy').authorize('manage', [data.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: user, data: updated || {},
}; };
} }
@ -217,8 +224,8 @@ export default class UserService {
* @param id User id * @param id User id
* @returns Response * @returns Response
*/ */
public async deleteUser(id: string | number): Promise<IResponse> { public async deleteUser(id: string | number, ctx: HttpContextContract): Promise<IResponse> {
const user = await this.userRepository.delete(id); const user = await this.userRepository.getById(id);
if (!user) { if (!user) {
return { return {
@ -232,6 +239,15 @@ 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,

View File

@ -56,7 +56,6 @@ class RedisSessionStore {
public async getSessions(): Promise<Array<SessionDataType | undefined>> { public async getSessions(): Promise<Array<SessionDataType | undefined>> {
const keys = await this.scanSessionKeys(); const keys = await this.scanSessionKeys();
const commands = Array.from(keys).map(key => { const commands = Array.from(keys).map(key => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [, ...id] = key.split('-'); const [, ...id] = key.split('-');
return ['hmget', id.join('-'), 'userId', 'userName', 'connected']; return ['hmget', id.join('-'), 'userId', 'userName', 'connected'];
}); });
@ -64,7 +63,10 @@ class RedisSessionStore {
return this.redisClient return this.redisClient
.multi(commands) .multi(commands)
.exec() .exec()
.then(results => results.map(([err, session]) => (err ? undefined : this.transform(session)))); .then(results => {
if (results === null) return [];
return results.map(([err, session]: [Error, (string | null)[]]) => (err ? undefined : this.transform(session)));
});
} }
/** /**

View File

@ -7,7 +7,7 @@ export default class CreateContactsValidator {
public schema = schema.create({ public schema = schema.create({
phone_number: schema.string.nullableAndOptional({}, [ phone_number: schema.string.nullableAndOptional({}, [
rules.mobile({ rules.mobile({
locales: ['ru-RU', 'en-US'], locale: ['ru-RU', 'en-US'],
strict: true, strict: true,
}), }),
]), ]),

View File

@ -7,7 +7,7 @@ export default class UpdateContactsValidator {
public schema = schema.create({ public schema = schema.create({
phone_number: schema.string.nullableAndOptional({}, [ phone_number: schema.string.nullableAndOptional({}, [
rules.mobile({ rules.mobile({
locales: ['ru-RU', 'en-US'], locale: ['ru-RU', 'en-US'],
strict: true, strict: true,
}), }),
rules.unique({ table: 'contacts', column: 'phone_number' }), rules.unique({ table: 'contacts', column: 'phone_number' }),

View File

@ -11,7 +11,7 @@ export default class CreateCourseValidator {
education_description: schema.string.nullable(), education_description: schema.string.nullable(),
image: schema.file.optional({ image: schema.file.optional({
size: '10mb', size: '10mb',
extnames: ['jpg', 'jpeg', 'png'], extnames: ['jpg', 'jpeg', 'png', 'webp'],
}), }),
teacher_id: schema.string({}, [rules.exists({ table: 'users', column: 'id' })]), teacher_id: schema.string({}, [rules.exists({ table: 'users', column: 'id' })]),
category_id: schema.string({}, [rules.exists({ table: 'categories', column: 'id' })]), category_id: schema.string({}, [rules.exists({ table: 'categories', column: 'id' })]),

View File

@ -10,7 +10,7 @@ export default class UpdateCourseValidator {
education_description: schema.string.nullable(), education_description: schema.string.nullable(),
image: schema.file.optional({ image: schema.file.optional({
size: '10mb', size: '10mb',
extnames: ['jpg', 'jpeg', 'png'], extnames: ['jpg', 'jpeg', 'png', 'webp'],
}), }),
teacher_id: schema.string.optional({}, [rules.exists({ table: 'users', column: 'id' })]), teacher_id: schema.string.optional({}, [rules.exists({ table: 'users', column: 'id' })]),
category_id: schema.string.optional({}, [rules.exists({ table: 'categories', column: 'id' })]), category_id: schema.string.optional({}, [rules.exists({ table: 'categories', column: 'id' })]),

View File

@ -9,10 +9,11 @@ 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({ video: schema.file.optional({
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',

View File

@ -13,6 +13,7 @@ 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',

View File

@ -7,7 +7,7 @@ export default class UpdateUserInfoValidator {
public schema = schema.create({ public schema = schema.create({
about: schema.string.nullableAndOptional(), about: schema.string.nullableAndOptional(),
phone_number: schema.string.nullableAndOptional({}, [ phone_number: schema.string.nullableAndOptional({}, [
rules.mobile({ locales: ['ru-RU', 'en-US'], strict: true }), rules.mobile({ locale: ['ru-RU', 'en-US'], strict: true }),
rules.unique({ table: 'contacts', column: 'phone_number' }), rules.unique({ table: 'contacts', column: 'phone_number' }),
]), ]),
vk_id: schema.string.nullableAndOptional({}, [ vk_id: schema.string.nullableAndOptional({}, [

View File

@ -106,8 +106,8 @@ export const http: ServerConfig = {
path: '/', path: '/',
maxAge: '1d', maxAge: '1d',
httpOnly: true, httpOnly: true,
secure: false, secure: true,
sameSite: false, sameSite: 'none',
}, },
/* /*

View File

@ -6,7 +6,7 @@
*/ */
import Env from '@ioc:Adonis/Core/Env'; import Env from '@ioc:Adonis/Core/Env';
import { DriveConfig } from '@ioc:Adonis/Core/Drive'; import { driveConfig } from '@adonisjs/core/build/config';
import Application from '@ioc:Adonis/Core/Application'; import Application from '@ioc:Adonis/Core/Application';
/* /*
@ -18,7 +18,7 @@ import Application from '@ioc:Adonis/Core/Application';
| defined inside the `contracts` directory. | defined inside the `contracts` directory.
| |
*/ */
const driveConfig: DriveConfig = { export default driveConfig({
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Default disk | Default disk
@ -143,6 +143,4 @@ const driveConfig: DriveConfig = {
// usingUniformAcl: false // usingUniformAcl: false
// }, // },
}, },
}; });
export default driveConfig;

View File

@ -6,7 +6,7 @@
*/ */
import Env from '@ioc:Adonis/Core/Env'; import Env from '@ioc:Adonis/Core/Env';
import { HashConfig } from '@ioc:Adonis/Core/Hash'; import { hashConfig } from '@adonisjs/core/build/config';
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -17,7 +17,7 @@ import { HashConfig } from '@ioc:Adonis/Core/Hash';
| defined inside `contracts` directory. | defined inside `contracts` directory.
| |
*/ */
const hashConfig: HashConfig = { export default hashConfig({
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Default hasher | Default hasher
@ -70,6 +70,4 @@ const hashConfig: HashConfig = {
rounds: 10, rounds: 10,
}, },
}, },
}; });
export default hashConfig;

View File

@ -6,9 +6,9 @@
*/ */
import Env from '@ioc:Adonis/Core/Env'; import Env from '@ioc:Adonis/Core/Env';
import { MailConfig } from '@ioc:Adonis/Addons/Mail'; import { mailConfig } from '@adonisjs/mail/build/config';
const mailConfig: MailConfig = { export default mailConfig({
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Default mailer | Default mailer
@ -18,7 +18,7 @@ const mailConfig: MailConfig = {
| a mailer | a mailer
| |
*/ */
mailer: 'mailgun', mailer: Env.get('MAILER'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -71,6 +71,4 @@ const mailConfig: MailConfig = {
domain: Env.get('MAILGUN_DOMAIN'), domain: Env.get('MAILGUN_DOMAIN'),
}, },
}, },
}; });
export default mailConfig;

View File

@ -1,16 +1,6 @@
import { InferDisksFromConfig } from '@adonisjs/core/build/config';
import driveConfig from '../config/drive';
declare module '@ioc:Adonis/Core/Drive' { declare module '@ioc:Adonis/Core/Drive' {
interface DisksList { interface DisksList extends InferDisksFromConfig<typeof driveConfig> {}
local: {
config: LocalDriverConfig;
implementation: LocalDriverContract;
};
s3: {
config: S3DriverConfig;
implementation: S3DriverContract;
};
// gcs: {
// config: GcsDriverConfig
// implementation: GcsDriverContract
// }
}
} }

View File

@ -1,19 +1,6 @@
/** import { InferListFromConfig } from '@adonisjs/core/build/config';
* Contract source: https://git.io/Jfefs import hashConfig from '../config/hash';
*
* Feel free to let us know via PR, if you find something broken in this contract
* file.
*/
declare module '@ioc:Adonis/Core/Hash' { declare module '@ioc:Adonis/Core/Hash' {
interface HashersList { interface HashersList extends InferListFromConfig<typeof hashConfig> {}
bcrypt: {
config: BcryptConfig;
implementation: BcryptContract;
};
argon: {
config: ArgonConfig;
implementation: ArgonContract;
};
}
} }

View File

@ -1,15 +1,6 @@
/** import { InferMailersFromConfig } from '@adonisjs/mail/build/config';
* Contract source: https://git.io/JvgAT import mailConfig from '../config/mail';
*
* Feel free to let us know via PR, if you find something broken in this contract
* file.
*/
declare module '@ioc:Adonis/Addons/Mail' { declare module '@ioc:Adonis/Addons/Mail' {
import { MailDrivers } from '@ioc:Adonis/Addons/Mail'; interface MailersList extends InferMailersFromConfig<typeof mailConfig> {}
interface MailersList {
smtp: MailDrivers['smtp'];
mailgun: MailDrivers['mailgun'];
}
} }

View File

@ -1,4 +1,4 @@
import Factory from '@ioc:Adonis/Lucid/Factory'; import Factory, { FactoryContextContract } from '@ioc:Adonis/Lucid/Factory';
import { string } from '@ioc:Adonis/Core/Helpers'; import { string } from '@ioc:Adonis/Core/Helpers';
/** /**
@ -18,7 +18,7 @@ import Role from 'App/Models/Role';
import User from 'App/Models/User'; import User from 'App/Models/User';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
const makeFakeUser = (faker: Faker.FakerStatic, loginPrefix?: string) => { const makeFakeUser = (faker: FactoryContextContract['faker'], loginPrefix?: string) => {
return { return {
first_name: faker.name.firstName(), first_name: faker.name.firstName(),
last_name: faker.name.lastName(), last_name: faker.name.lastName(),

View File

@ -11,6 +11,7 @@ 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 });

View File

@ -7,7 +7,13 @@ export default class CourseSeeder extends BaseSeeder {
public async run() { public async run() {
this.CourseFactory = CourseFactory; 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('category')
.with('teacher') .with('teacher')
.createMany(3); .createMany(3);

View File

@ -11,23 +11,48 @@ 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 userAdmin = await User.create({ const administrator = 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 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); await StudentFactory.with('contacts').createMany(20);
} }

4
env.ts
View File

@ -15,11 +15,10 @@
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(),
NODE_ENV: Env.schema.enum(['development', 'production', 'testing'] as const), NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
DRIVE_DISK: Env.schema.enum(['local', 's3'] as const), DRIVE_DISK: Env.schema.enum(['local', 's3'] as const),
PG_HOST: Env.schema.string({ format: 'host' }), PG_HOST: Env.schema.string({ format: 'host' }),
PG_PORT: Env.schema.number(), PG_PORT: Env.schema.number(),
@ -30,6 +29,7 @@ export default Env.rules({
REDIS_HOST: Env.schema.string({ format: 'host' }), REDIS_HOST: Env.schema.string({ format: 'host' }),
REDIS_PORT: Env.schema.number(), REDIS_PORT: Env.schema.number(),
REDIS_PASSWORD: Env.schema.string.optional(), REDIS_PASSWORD: Env.schema.string.optional(),
MAILER: Env.schema.enum(['mailgun', 'smtp'] as const),
SMTP_HOST: Env.schema.string({ format: 'host' }), SMTP_HOST: Env.schema.string({ format: 'host' }),
SMTP_PORT: Env.schema.number(), SMTP_PORT: Env.schema.number(),
SMTP_USERNAME: Env.schema.string(), SMTP_USERNAME: Env.schema.string(),

16019
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "1.3.1", "version": "1.4.5",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node ace build --production", "build": "node ace build --production",
@ -15,44 +15,44 @@
"db:seed:force": "node ace seed --force" "db:seed:force": "node ace seed --force"
}, },
"devDependencies": { "devDependencies": {
"@adonisjs/assembler": "^5.3.7", "@adonisjs/assembler": "^5.7.0",
"@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^4.30.0", "@typescript-eslint/parser": "^5.27.0",
"adonis-preset-ts": "^2.1.0", "adonis-preset-ts": "^2.1.0",
"eslint": "^7.32.0", "eslint": "^8.17.0",
"eslint-config-airbnb-base": "^14.2.1", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^12.3.1", "eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.24.2", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^3.4.1", "eslint-plugin-prettier": "^4.0.0",
"pino-pretty": "^5.1.3", "pino-pretty": "^8.0.0",
"prettier": "^2.3.2", "prettier": "^2.6.2",
"typescript": "^4.4.2", "typescript": "^4.7.3",
"youch": "^2.2.2", "youch": "^3.2.0",
"youch-terminal": "^1.1.1" "youch-terminal": "^2.1.4"
}, },
"dependencies": { "dependencies": {
"@adonisjs/application": "^5.2.0", "@adonisjs/application": "^5.2.5",
"@adonisjs/attachment-lite": "^1.0.1", "@adonisjs/attachment-lite": "^1.0.7",
"@adonisjs/auth": "^8.0.10", "@adonisjs/auth": "^8.2.1",
"@adonisjs/bouncer": "^2.2.5", "@adonisjs/bouncer": "^2.3.0",
"@adonisjs/core": "^5.4.2", "@adonisjs/core": "^5.8.3",
"@adonisjs/drive": "^2.0.7", "@adonisjs/drive": "^2.2.0",
"@adonisjs/drive-s3": "^1.0.5", "@adonisjs/drive-s3": "^1.3.1",
"@adonisjs/lucid": "^16.3.2", "@adonisjs/lucid": "^18.0.1",
"@adonisjs/mail": "^7.2.4", "@adonisjs/mail": "^8.1.2",
"@adonisjs/redis": "^7.1.0", "@adonisjs/redis": "^7.3.0",
"@adonisjs/repl": "^3.1.7", "@adonisjs/repl": "^3.1.11",
"@adonisjs/view": "^6.1.1", "@adonisjs/view": "^6.1.5",
"@types/socket.io": "^3.0.2", "@types/socket.io": "^3.0.2",
"cloudinary": "^1.26.3", "cloudinary": "^1.26.3",
"luxon": "^1.28.0", "luxon": "^2.4.0",
"nanoid": "^3.1.25", "nanoid": "^3.3.4",
"pg": "^8.7.1", "pg": "^8.7.3",
"phc-bcrypt": "^1.0.7", "phc-bcrypt": "^1.0.7",
"proxy-addr": "^2.0.7", "proxy-addr": "^2.0.7",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"socket.io": "^4.4.1", "socket.io": "^4.5.1",
"source-map-support": "^0.5.19" "source-map-support": "^0.5.21"
} }
} }

View File

@ -21,5 +21,3 @@ const server = new Ignitor(__dirname).httpServer();
server.start().catch(error => { server.start().catch(error => {
throw new Error(error); throw new Error(error);
}); });
process.on('SIGINT', () => server.kill(10));

View File

@ -6,11 +6,6 @@
*/ */
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';
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -34,22 +29,7 @@ import User from 'App/Models/User';
| NOTE: Always export the "actions" const from this file | NOTE: Always export the "actions" const from this file
|**************************************************************** |****************************************************************
*/ */
export const { actions } = Bouncer.define('manageUserRole', async (user: User, role: Role) => { export const { actions } = Bouncer;
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;
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -74,4 +54,7 @@ export const { actions } = Bouncer.define('manageUserRole', async (user: User, r
| 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'),
});

View File

@ -3,4 +3,4 @@ import Route from '@ioc:Adonis/Core/Route';
Route.group(() => { Route.group(() => {
Route.post('login', 'Api/v1/AuthController.login').as('auth.login'); Route.post('login', 'Api/v1/AuthController.login').as('auth.login');
Route.post('logout', 'Api/v1/AuthController.logout').middleware('auth').as('auth.logout'); Route.post('logout', 'Api/v1/AuthController.logout').middleware('auth').as('auth.logout');
}).prefix('api/v1/auth'); }).prefix('v1/auth');

View File

@ -7,5 +7,5 @@ Route.group(() => {
Route.delete('/:id', 'Api/v1/CategoriesController.delete').middleware('role:admin,teacher').as('categories.delete'); Route.delete('/:id', 'Api/v1/CategoriesController.delete').middleware('role:admin,teacher').as('categories.delete');
Route.patch('/:id', 'Api/v1/CategoriesController.update').middleware('role:admin,teacher').as('categories.update'); Route.patch('/:id', 'Api/v1/CategoriesController.update').middleware('role:admin,teacher').as('categories.update');
}) })
.prefix('api/v1/categories') .prefix('v1/categories')
.middleware('auth'); .middleware('auth');

View File

@ -5,5 +5,5 @@ Route.group(() => {
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('chat.get-conversations'); .as('chat.get-conversations');
}) })
.prefix('api/v1/chat') .prefix('v1/chat')
.middleware('auth'); .middleware('auth');

View File

@ -62,5 +62,5 @@ Route.group(() => {
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('courses.get-lessons-progress'); .as('courses.get-lessons-progress');
}) })
.prefix('api/v1/courses') .prefix('v1/courses')
.middleware('auth'); .middleware('auth');

View File

@ -9,5 +9,5 @@ Route.group(() => {
return report.healthy ? ctx.response.ok(report) : ctx.response.badRequest(report); return report.healthy ? ctx.response.ok(report) : ctx.response.badRequest(report);
}).as('health'); }).as('health');
}) })
.prefix('api/v1/health') .prefix('v1/health')
.middleware('auth'); .middleware('auth');

View File

@ -6,5 +6,5 @@ Route.group(() => {
Route.get('/', 'Api/v1/ImagesController.list').as('images.list'); Route.get('/', 'Api/v1/ImagesController.list').as('images.list');
Route.get('/:id', 'Api/v1/ImagesController.show').as('images.show'); Route.get('/:id', 'Api/v1/ImagesController.show').as('images.show');
}) })
.prefix('api/v1/images') .prefix('v1/images')
.middleware('auth'); .middleware('auth');

View File

@ -19,5 +19,5 @@ Route.group(() => {
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('lessons.get-video-progress'); .as('lessons.get-video-progress');
}) })
.prefix('api/v1/lessons') .prefix('v1/lessons')
.middleware('auth'); .middleware('auth');

View File

@ -19,5 +19,5 @@ Route.group(() => {
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('me.update-contacts'); .as('me.update-contacts');
}) })
.prefix('api/v1/me') .prefix('v1/me')
.middleware('auth'); .middleware('auth');

View File

@ -3,5 +3,5 @@ import Route from '@ioc:Adonis/Core/Route';
Route.group(() => { Route.group(() => {
Route.get('/', 'Api/v1/StatController.index').middleware('role:admin,teacher,student').as('stat.index'); Route.get('/', 'Api/v1/StatController.index').middleware('role:admin,teacher,student').as('stat.index');
}) })
.prefix('api/v1/stat') .prefix('v1/stat')
.middleware('auth'); .middleware('auth');

View File

@ -7,11 +7,11 @@ 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,teacher') .middleware('role:admin')
.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')
.as('users.detach-role'); .as('users.detach-role');
}) })
.prefix('api/v1/users') .prefix('v1/users')
.middleware('auth'); .middleware('auth');

View File

@ -1,30 +1,17 @@
{ {
"extends": "./node_modules/adonis-preset-ts/tsconfig", "extends": "./node_modules/adonis-preset-ts/tsconfig",
"include": [ "include": ["**/*"],
"**/*" "exclude": ["node_modules", "build"],
],
"exclude": [
"node_modules",
"build"
],
"compilerOptions": { "compilerOptions": {
"outDir": "build", "outDir": "build",
"rootDir": "./", "rootDir": "./",
"sourceMap": true, "sourceMap": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,
"paths": { "paths": {
"App/*": [ "App/*": ["./app/*"],
"./app/*" "Config/*": ["./config/*"],
], "Contracts/*": ["./contracts/*"],
"Config/*": [ "Database/*": ["./database/*"]
"./config/*"
],
"Contracts/*": [
"./contracts/*"
],
"Database/*": [
"./database/*"
]
}, },
"types": [ "types": [
"@adonisjs/core", "@adonisjs/core",