From cd4eb2513c4910702afb24b57d5b765c62e20806 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Fri, 31 Dec 2021 01:14:18 +0300 Subject: [PATCH] feat: added lesson video model --- .../Http/Api/v1/LessonsController.ts | 41 ++++++++++------- app/Controllers/Http/BaseController.ts | 18 ++++++++ app/Models/Lesson.ts | 8 +++- app/Models/LessonVideo.ts | 38 ++++++++++++++++ app/Repositories/LessonRepository.ts | 44 +++++++++++++++---- app/Services/LessonService.ts | 41 +++++++++++++++++ .../Lesson/CreateLessonValidator.ts | 4 ++ app/Validators/Validator.ts | 31 +++++++++++++ .../migrations/1640897230275_lesson_videos.ts | 24 ++++++++++ routes/apis/v1/lessons.ts | 3 ++ 10 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 app/Models/LessonVideo.ts create mode 100644 app/Validators/Validator.ts create mode 100644 database/migrations/1640897230275_lesson_videos.ts diff --git a/app/Controllers/Http/Api/v1/LessonsController.ts b/app/Controllers/Http/Api/v1/LessonsController.ts index f25f214..63320e4 100644 --- a/app/Controllers/Http/Api/v1/LessonsController.ts +++ b/app/Controllers/Http/Api/v1/LessonsController.ts @@ -1,7 +1,6 @@ import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone'; import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import { schema } from '@ioc:Adonis/Core/Validator'; -import { extname } from 'path'; /** * Services @@ -12,6 +11,7 @@ import LessonService from 'App/Services/LessonService'; * Models */ import LessonMaterial from 'App/Models/LessonMaterial'; +import LessonVideo from 'App/Models/LessonVideo'; /** * Validators @@ -19,7 +19,6 @@ import LessonMaterial from 'App/Models/LessonMaterial'; import CreateLessonValidator from 'App/Validators/Lesson/CreateLessonValidator'; import UpdateLessonValidator from 'App/Validators/Lesson/UpdateLessonValidator'; -import Drive from '@ioc:Adonis/Core/Drive'; import BaseController from '../../BaseController'; @inject() @@ -133,21 +132,7 @@ export default class LessonsController extends BaseController { * Send file to user */ if (data instanceof LessonMaterial) { - const path = `materials/${data.name}`; - - /** - * Material file not found - */ - if (!(await Drive.exists(path))) return ctx.response.notFound(); - - const { size } = await Drive.getStats(path); - - ctx.response.type(extname(data.name)); - ctx.response.header('content-length', size); - - const stream = await Drive.getStream(path); - - return this.sendStream(ctx, stream); + this.sendFileFromDrive(`materials/${data.name}`, ctx); } return this.sendResponse(ctx, data, message, status); @@ -185,6 +170,28 @@ export default class LessonsController extends BaseController { return this.sendResponse(ctx, data, message, status); } + + /** + * Get video file of lesson by file name + * GET /lessons/video/:fileName + */ + public async getVideo(ctx: HttpContextContract) { + const { fileName } = ctx.request.params(); + const { data, message, status, success, error } = await this.lessonService.fetchVideoFile(ctx, fileName); + + if (!success && error) { + throw new Exception(message, status, error.code); + } + + /** + * Send file to user + */ + if (data instanceof LessonVideo) { + return this.sendFileFromDrive(`videos/${data.name}`, ctx); + } + + return this.sendResponse(ctx, data, message, status); + } } new Ioc().make(LessonsController); diff --git a/app/Controllers/Http/BaseController.ts b/app/Controllers/Http/BaseController.ts index 960eced..233532e 100644 --- a/app/Controllers/Http/BaseController.ts +++ b/app/Controllers/Http/BaseController.ts @@ -1,6 +1,8 @@ /* eslint-disable class-methods-use-this */ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import { ResponseContract } from '@ioc:Adonis/Core/Response'; +import Drive from '@ioc:Adonis/Core/Drive'; +import { extname } from 'path'; export default class BaseController { public sendResponse(ctx: HttpContextContract, data: object, message: string, status = 200, meta?: any): void { @@ -21,4 +23,20 @@ export default class BaseController { public sendFile(ctx: HttpContextContract, ...args: Parameters) { return ctx.response.attachment(...args); } + + public async sendFileFromDrive(pathname: string, ctx: HttpContextContract) { + /** + * File not found + */ + if (!(await Drive.exists(pathname))) return ctx.response.notFound(); + + const { size } = await Drive.getStats(pathname); + + ctx.response.type(extname(pathname)); + ctx.response.header('content-length', size); + + const stream = await Drive.getStream(pathname); + + return this.sendStream(ctx, stream); + } } diff --git a/app/Models/Lesson.ts b/app/Models/Lesson.ts index 63ae5e0..973d17a 100644 --- a/app/Models/Lesson.ts +++ b/app/Models/Lesson.ts @@ -17,8 +17,9 @@ import ColorHelper from 'App/Helpers/ColorHelper'; import Course from './Course'; import LessonMaterial from './LessonMaterial'; import LessonContent from './LessonContent'; -import Color from './Color'; import LessonProgress from './LessonProgress'; +import LessonVideo from './LessonVideo'; +import Color from './Color'; export default class Lesson extends BaseModel { @column({ isPrimary: true }) @@ -57,6 +58,11 @@ export default class Lesson extends BaseModel { }) public content: HasOne; + @hasOne(() => LessonVideo, { + foreignKey: 'lesson_id', + }) + public video: HasOne; + @hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' }) public materials: HasMany; diff --git a/app/Models/LessonVideo.ts b/app/Models/LessonVideo.ts new file mode 100644 index 0000000..87975fb --- /dev/null +++ b/app/Models/LessonVideo.ts @@ -0,0 +1,38 @@ +/* eslint-disable import/no-cycle */ +import { DateTime } from 'luxon'; +import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm'; +import Lesson from './Lesson'; + +export default class LessonVideo extends BaseModel { + @column({ isPrimary: true }) + public id: number; + + @column({ serializeAs: null }) + public lesson_id: string; + + @column() + public name: string; + + @column() + public size: number; + + @column() + public clientName: string; + + @column() + public ext: string; + + @column() + public url: string; + + @belongsTo(() => Lesson, { + foreignKey: 'lesson_id', + }) + public lesson: BelongsTo; + + @column.dateTime({ autoCreate: true }) + public createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + public updatedAt: DateTime; +} diff --git a/app/Repositories/LessonRepository.ts b/app/Repositories/LessonRepository.ts index 1f18662..7813711 100644 --- a/app/Repositories/LessonRepository.ts +++ b/app/Repositories/LessonRepository.ts @@ -7,6 +7,7 @@ import { cuid } from '@ioc:Adonis/Core/Helpers'; import Course from 'App/Models/Course'; import Lesson from 'App/Models/Lesson'; import LessonMaterial from 'App/Models/LessonMaterial'; +import LessonVideo from 'App/Models/LessonVideo'; /** * Validators @@ -19,11 +20,14 @@ export default class LessonRepository { private LessonMaterial: typeof LessonMaterial; + private LessonVideo: typeof LessonVideo; + private Drive: typeof Drive; constructor() { this.Lesson = Lesson; this.LessonMaterial = LessonMaterial; + this.LessonVideo = LessonVideo; this.Drive = Drive; } @@ -81,6 +85,11 @@ export default class LessonRepository { return material; } + public async getVideoFileByName(fileName: string): Promise { + const video = await this.LessonVideo.query().where('name', fileName).first(); + return video; + } + /** * Create lesson * @@ -95,18 +104,29 @@ export default class LessonRepository { description: data.description, duration: data.duration.toFormat('HH:mm:ss'), display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1, + course_id: course.id, }); - /** - * Attach lesson to course - */ - await lesson.related('course').associate(course); - /** * Create content */ await lesson.related('content').create({ video_url: data.video_url }); + /** + * Move video file to disk and save to database + */ + const videoName = `${cuid()}.${data.video.extname}`; + await data.video.moveToDisk('videos', { name: videoName }); + if (data.video.state === 'moved') { + await lesson.related('video').create({ + name: videoName, + clientName: data.video.clientName, + ext: data.video.extname, + size: data.video.size, + url: `lessons/video/${videoName}`, + }); + } + /** * Create materials */ @@ -150,7 +170,7 @@ export default class LessonRepository { /** * Load data */ - await lesson.load(loader => loader.load('content').load('materials')); + await lesson.load(loader => loader.load('content').load('materials').load('video')); return lesson; } @@ -185,15 +205,21 @@ export default class LessonRepository { * @returns Deleted lesson or null */ public async delete(id: string | number): Promise { - const lesson = await this.Lesson.query().where('id', id).preload('materials').preload('color').first(); + const lesson = await this.Lesson.query() + .where('id', id) + .preload('video') + .preload('materials') + .preload('color') + .first(); if (lesson) { /** * Delete files from disk */ + await this.Drive.delete(`videos/${lesson.video.name}`); await Promise.all( - lesson.materials.map(async file => { - await this.Drive.delete(`materials/${file.name}`); + lesson.materials.map(async material => { + await this.Drive.delete(`materials/${material.name}`); }) ); diff --git a/app/Services/LessonService.ts b/app/Services/LessonService.ts index ed6992f..002fb10 100644 --- a/app/Services/LessonService.ts +++ b/app/Services/LessonService.ts @@ -8,6 +8,7 @@ import Database from '@ioc:Adonis/Lucid/Database'; import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum'; import IResponse from 'App/Datatypes/Interfaces/IResponse'; import LessonMaterial from 'App/Models/LessonMaterial'; +import LessonVideo from 'App/Models/LessonVideo'; /** * Repositories @@ -151,6 +152,43 @@ export default class LessonService { }; } + public async fetchVideoFile(ctx: HttpContextContract, name: string): Promise> { + const video = await this.lessonRepository.getVideoFileByName(name); + + if (!video) { + return { + success: false, + status: HttpStatusEnum.NOT_FOUND, + data: {}, + message: 'Video not found.', + error: { + code: 'E_NOT_FOUND', + }, + }; + } + + await video.load('lesson'); + + if (await ctx.bouncer.denies('viewLessonContent', video.lesson)) { + return { + success: false, + status: HttpStatusEnum.FORBIDDEN, + message: 'You are not able to view this file.', + data: {}, + error: { + code: 'E_FORBIDDEN', + }, + }; + } + + return { + success: true, + status: HttpStatusEnum.OK, + message: 'Fetched lesson video.', + data: video, + }; + } + /** * Fetch progress of lesson by lesson id * @@ -222,6 +260,9 @@ export default class LessonService { }; } + /** + * Create new lesson + */ const lesson = await this.lessonRepository.create(course, data); return { diff --git a/app/Validators/Lesson/CreateLessonValidator.ts b/app/Validators/Lesson/CreateLessonValidator.ts index 5a82dba..a8a8c95 100644 --- a/app/Validators/Lesson/CreateLessonValidator.ts +++ b/app/Validators/Lesson/CreateLessonValidator.ts @@ -10,6 +10,10 @@ export default class CreateLessonValidator { description: schema.string(), duration: schema.date({ format: 'HH:mm:ss' }), video_url: schema.string({}, [rules.url()]), + video: schema.file({ + size: '5000mb', + extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'], + }), materials: schema.array.optional().members( schema.file({ size: '10mb', diff --git a/app/Validators/Validator.ts b/app/Validators/Validator.ts new file mode 100644 index 0000000..5eeef80 --- /dev/null +++ b/app/Validators/Validator.ts @@ -0,0 +1,31 @@ +import { validator, ParsedTypedSchema } from '@ioc:Adonis/Core/Validator'; + +export default abstract class Validator { + /** + * Object for schemas. + * Each key in object will correspong to a type of validation. + * Each values will be a valid Adonis schema, made using schema.create + */ + protected abstract schemas(type: string): ParsedTypedSchema; + + /** + * Object of error message objects used to pass custom error messages. + * Each key will correspond to a valid validation type defined in schemas + */ + protected errorMessages = {}; + + /** + * Cache key, to be used if validator schema caching is needed. + * Refer https://preview.adonisjs.com/guides/validator/schema-caching + */ + protected cacheKey: string; + + public async fire(data: { [key: string]: any }, type: string): Promise { + return validator.validate({ + schema: this.schemas(type), + data, + ...(this.errorMessages[type] ? { messages: this.errorMessages[type] } : {}), + ...(this.cacheKey ? { cacheKey: this.cacheKey } : {}), + }); + } +} diff --git a/database/migrations/1640897230275_lesson_videos.ts b/database/migrations/1640897230275_lesson_videos.ts new file mode 100644 index 0000000..dcd7118 --- /dev/null +++ b/database/migrations/1640897230275_lesson_videos.ts @@ -0,0 +1,24 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema'; + +export default class LessonVideos extends BaseSchema { + protected tableName = 'lesson_videos'; + + public async up() { + this.schema.createTable(this.tableName, table => { + table.increments('id'); + table.string('lesson_id', 21).unsigned().references('lessons.id').onDelete('CASCADE').notNullable(); + table.string('name').notNullable(); + table.integer('size').notNullable(); + table.string('client_name').notNullable(); + table.string('ext').notNullable(); + table.string('url').notNullable(); + table.timestamp('created_at', { useTz: true }); + table.timestamp('updated_at', { useTz: true }); + table.unique(['lesson_id']); + }); + } + + public async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/routes/apis/v1/lessons.ts b/routes/apis/v1/lessons.ts index ba16db5..806ef1b 100644 --- a/routes/apis/v1/lessons.ts +++ b/routes/apis/v1/lessons.ts @@ -15,6 +15,9 @@ Route.group(() => { Route.get('/materials/:fileName', 'Api/v1/LessonsController.getMaterial') .middleware('role:admin,teacher,student') .as('lessons.get-material'); + Route.get('/video/:fileName', 'Api/v1/LessonsController.getVideo') + .middleware('role:admin,teacher,student') + .as('lessons.get-video'); Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress') .middleware('role:admin,teacher,student') .as('lessons.get-video-progress');