Merge branch 'release/1.1.0'

This commit is contained in:
Sergey Yarkov 2022-01-31 20:36:40 +03:00
commit a90dcf65cf
73 changed files with 2588 additions and 162 deletions

View File

@ -24,7 +24,12 @@
"Database": "database", "Database": "database",
"Contracts": "contracts" "Contracts": "contracts"
}, },
"preloads": ["./routes/index", "./start/kernel", "./start/event", "./start/bouncer"], "preloads": [
"./routes/index",
"./start/kernel",
"./start/event",
"./start/bouncer"
],
"providers": [ "providers": [
"./providers/AppProvider", "./providers/AppProvider",
"@adonisjs/core", "@adonisjs/core",
@ -34,9 +39,12 @@
"@adonisjs/mail", "@adonisjs/mail",
"@adonisjs/view", "@adonisjs/view",
"@adonisjs/attachment-lite", "@adonisjs/attachment-lite",
"@adonisjs/bouncer" "@adonisjs/bouncer",
"@adonisjs/drive-s3"
],
"aceProviders": [
"@adonisjs/repl"
], ],
"aceProviders": ["@adonisjs/repl"],
"metaFiles": [ "metaFiles": [
{ {
"pattern": "resources/views/**/*.edge", "pattern": "resources/views/**/*.edge",

View File

@ -19,3 +19,8 @@ SMTP_PASSWORD=<password>
MAILGUN_API_KEY=<mailgun-api-key> MAILGUN_API_KEY=<mailgun-api-key>
MAILGUN_DOMAIN=<your-domain> MAILGUN_DOMAIN=<your-domain>
CACHE_VIEWS=false CACHE_VIEWS=false
S3_KEY=dummyKey
S3_SECRET=dummySecret
S3_BUCKET=dummyBucket
S3_REGION=dummyRegion
S3_ENDPOINT=dummyEndpoint

View File

@ -4,17 +4,24 @@
- API deployment with Docker and Nginx web server. - API deployment with Docker and Nginx web server.
## [1.0.1] - 2021-11-25 ## [1.1.0] - 2022-01-31
### Added
- Cloud storate support (AWS S3)
- Websocket server with redis session storage (online counter)
- Videos controller in api (video viewing rights)
- Categories updating operations
- Statistics controller in api (online count, lessonc count, courses count)
- Materials count for each lessons
- Users pagination
- Batch actions with students (detaching from course)
### Fix ### Fix
- Creating materials for lesson - Creating materials for lesson
- Contacts updating - Contacts updating
### Added
- Materials count for each lessons
## [1.0.0] - 2021-11-25 ## [1.0.0] - 2021-11-25
### Added ### Added

View File

@ -83,7 +83,9 @@
] ]
}, },
"make:model": { "make:model": {
"settings": {}, "settings": {
"loadApp": true
},
"commandPath": "@adonisjs/lucid/build/commands/MakeModel", "commandPath": "@adonisjs/lucid/build/commands/MakeModel",
"commandName": "make:model", "commandName": "make:model",
"description": "Make a new Lucid model", "description": "Make a new Lucid model",

View File

@ -7,9 +7,10 @@ import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
import CategoryService from 'App/Services/CategoryService'; import CategoryService from 'App/Services/CategoryService';
/** /**
* Validatoes * Validators
*/ */
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator'; import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
import BaseController from '../../BaseController'; import BaseController from '../../BaseController';
@ -78,6 +79,21 @@ export default class CategoriesController extends BaseController {
return this.sendResponse(ctx, result.data, result.message, result.status); return this.sendResponse(ctx, result.data, result.message, result.status);
} }
/**
* Update category by id
* PATCH /categories/:id
*/
public async update(ctx: HttpContextContract) {
const payload = await ctx.request.validate(UpdateCategoryValidator);
const result = await this.categoryService.updateCategory(ctx.params.id, payload);
if (!result.success && result.error) {
throw new Exception(result.message, result.status, result.error.code);
}
return this.sendResponse(ctx, result.data, result.message, result.status);
}
} }
new Ioc().make(CategoriesController); new Ioc().make(CategoriesController);

View File

@ -10,6 +10,8 @@ import CourseService from 'App/Services/CourseService';
* Validators * Validators
*/ */
import AddCourseToUserValidator from 'App/Validators/Course/AddCourseToUserValidator'; import AddCourseToUserValidator from 'App/Validators/Course/AddCourseToUserValidator';
import AttachStudentsValitator from 'App/Validators/Course/AttachStudentsValitator';
import DetachStudentstValitator from 'App/Validators/Course/DetachStudentstValitator';
import CreateCourseValidator from 'App/Validators/Course/CreateCourseValidator'; import CreateCourseValidator from 'App/Validators/Course/CreateCourseValidator';
import DelCourseFromUserValidator from 'App/Validators/Course/DelCourseFromUserValidator'; import DelCourseFromUserValidator from 'App/Validators/Course/DelCourseFromUserValidator';
import SetCourseStatusValidator from 'App/Validators/Course/SetCourseStatusValidator'; import SetCourseStatusValidator from 'App/Validators/Course/SetCourseStatusValidator';
@ -71,7 +73,7 @@ export default class CoursesController extends BaseController {
} }
/** /**
* Show Category of Course with "id" * Show Category by course id
* GET /courses/:id/category * GET /courses/:id/category
*/ */
public async showCategory(ctx: HttpContextContract) { public async showCategory(ctx: HttpContextContract) {
@ -85,11 +87,11 @@ export default class CoursesController extends BaseController {
} }
/** /**
* Show Lessons of ourse with "id" * Show Lessons by course id
* GET /courses/:id/lessons * GET /courses/:id/lessons
*/ */
public async showLessons(ctx: HttpContextContract) { public async showLessons(ctx: HttpContextContract) {
const result = await this.courseService.fetchCourseLessons(ctx.params.id); const result = await this.courseService.fetchCourseLessons(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);
@ -171,6 +173,21 @@ export default class CoursesController extends BaseController {
return this.sendResponse(ctx, result.data, result.message, result.status); return this.sendResponse(ctx, result.data, result.message, result.status);
} }
/**
* Attach list of students to course
* POST /courses/:id/attach-student-list
*/
public async attachStudentList(ctx: HttpContextContract) {
const payload = await ctx.request.validate(AttachStudentsValitator);
const result = await this.courseService.attachStudentList(ctx.params.id, payload.students);
if (!result.success && result.error) {
throw new Exception(result.message, result.status, result.error.code);
}
return this.sendResponse(ctx, result.data, result.message, result.status);
}
/** /**
* Detach student from course * Detach student from course
* DELETE /courses/:id/detach-student * DELETE /courses/:id/detach-student
@ -186,6 +203,21 @@ export default class CoursesController extends BaseController {
return this.sendResponse(ctx, result.data, result.message, result.status); return this.sendResponse(ctx, result.data, result.message, result.status);
} }
/**
* Detach list of students to course
* POST /courses/:id/detach-student-list
*/
public async detachStudentList(ctx: HttpContextContract) {
const payload = await ctx.request.validate(DetachStudentstValitator);
const result = await this.courseService.detachStudentList(ctx.params.id, payload.students);
if (!result.success && result.error) {
throw new Exception(result.message, result.status, result.error.code);
}
return this.sendResponse(ctx, result.data, result.message, result.status);
}
/** /**
* Fetch count of students of course * Fetch count of students of course
* GET /courses/:id/students/count * GET /courses/:id/students/count

View File

@ -1,4 +1,3 @@
import Application from '@ioc:Adonis/Core/Application';
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 } from '@ioc:Adonis/Core/Validator';
@ -49,7 +48,7 @@ export default class LessonsController extends BaseController {
* GET /lessons/:id * GET /lessons/:id
*/ */
public async show(ctx: HttpContextContract) { public async show(ctx: HttpContextContract) {
const result = await this.lessonService.fetchLesson(ctx.params.id); const result = await this.lessonService.fetchLesson(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);
@ -89,7 +88,7 @@ export default class LessonsController extends BaseController {
/** /**
* Update lesson by id. * Update lesson by id.
* PUT /lessons/:id * PATCH /lessons/:id
*/ */
public async update(ctx: HttpContextContract) { public async update(ctx: HttpContextContract) {
const payload = await ctx.request.validate(UpdateLessonValidator); const payload = await ctx.request.validate(UpdateLessonValidator);
@ -118,22 +117,18 @@ export default class LessonsController extends BaseController {
/** /**
* Get lesson materials by file name * Get lesson materials by file name
* GET /lessons/materials/:file * GET /lessons/materials/:fileName
*/ */
public async getMaterial(ctx: HttpContextContract) { public async getMaterial(ctx: HttpContextContract) {
const { file } = ctx.request.params(); const { fileName } = ctx.request.params();
const { data, message, status, success, error } = await this.lessonService.fetchMaterialFile(ctx, file); const { data, message, status, success, error } = await this.lessonService.fetchMaterialFile(ctx, fileName);
if (!success && error) { if (!success && error) {
throw new Exception(message, status, error.code); throw new Exception(message, status, error.code);
} }
/**
* Send file to user
*/
if (data instanceof LessonMaterial) { if (data instanceof LessonMaterial) {
const path = Application.makePath(data.url); return this.sendFileFromDrive(`materials/${data.name}`, ctx);
return this.sendFile(ctx, path, data.clientName);
} }
return this.sendResponse(ctx, data, message, status); return this.sendResponse(ctx, data, message, status);
@ -157,6 +152,20 @@ export default class LessonsController extends BaseController {
return this.sendResponse(ctx, data, message, status); return this.sendResponse(ctx, data, message, status);
} }
/**
* Get information about progress of video
* GET /lessons/:id/progress
*/
public async getVideoProgress(ctx: HttpContextContract) {
const { data, message, status, success, error } = await this.lessonService.fetchLessonProgress(ctx.params.id, ctx);
if (!success && error) {
throw new Exception(message, status, error.code);
}
return this.sendResponse(ctx, data, message, status);
}
} }
new Ioc().make(LessonsController); new Ioc().make(LessonsController);

View File

@ -0,0 +1,31 @@
import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import StatService from 'App/Services/StatService';
import BaseController from '../../BaseController';
@inject()
export default class StatController extends BaseController {
private statService: StatService;
constructor(statService: StatService) {
super();
this.statService = statService;
}
/**
* Return current stat about system
* GET /stat
*/
public async index(ctx: HttpContextContract) {
const result = await this.statService.fetchStatData();
if (!result.success && result.error) {
throw new Exception(result.message, result.status, result.error.code);
}
return this.sendResponse(ctx, result.data, result.message, result.status);
}
}
new Ioc().make(StatController);

View File

@ -1,6 +1,8 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import { ResponseContract } from '@ioc:Adonis/Core/Response'; import { ResponseContract } from '@ioc:Adonis/Core/Response';
import Drive from '@ioc:Adonis/Core/Drive';
import { extname } from 'path';
export default class BaseController { export default class BaseController {
public sendResponse(ctx: HttpContextContract, data: object, message: string, status = 200, meta?: any): void { public sendResponse(ctx: HttpContextContract, data: object, message: string, status = 200, meta?: any): void {
@ -14,7 +16,27 @@ export default class BaseController {
return ctx.response.status(response.status).send(response); return ctx.response.status(response.status).send(response);
} }
public sendStream(ctx: HttpContextContract, ...args: Parameters<ResponseContract['stream']>) {
return ctx.response.stream(...args);
}
public sendFile(ctx: HttpContextContract, ...args: Parameters<ResponseContract['attachment']>) { public sendFile(ctx: HttpContextContract, ...args: Parameters<ResponseContract['attachment']>) {
return ctx.response.attachment(...args); 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);
}
} }

View File

@ -0,0 +1,53 @@
import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
/**
* Models
*/
import LessonVideo from 'App/Models/LessonVideo';
/**
* Services
*/
import LessonService from 'App/Services/LessonService';
import BaseController from './BaseController';
@inject()
export default class VideosController extends BaseController {
private lessonService: LessonService;
constructor(lessonService: LessonService) {
super();
this.lessonService = lessonService;
}
/**
* Get video file of lesson by file name
* GET /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);
}
if (data instanceof LessonVideo) {
/**
* Allow user to control video
*/
ctx.response.header('Accept-Ranges', 'bytes');
/**
* Send video
*/
return this.sendFileFromDrive(`videos/${data.name}`, ctx);
}
return this.sendResponse(ctx, data, message, status);
}
}
new Ioc().make(VideosController);

View File

@ -0,0 +1,92 @@
import { Server, Socket } from 'socket.io';
import { ClientToServerEvents, InterServerEvents, ServerToClientEvents, SocketData } from 'App/Services/WsService';
import RedisSessionStore from 'App/Store/SessionStore';
export default class OnlineController {
public socket: Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
public io: Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
public sessionStore: RedisSessionStore;
constructor(
socket: Socket,
io: Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>,
sessionStore: RedisSessionStore
) {
this.socket = socket;
this.io = io;
this.sessionStore = sessionStore;
this.onConnected(socket);
this.socket.on('disconnect', reason => this.onDisconnected(reason));
}
public sendOnlineCount(online: number) {
this.socket.emit('user:online', online);
this.socket.broadcast.emit('user:online', online);
}
public setUserOnline() {
const { sessionId, userId, userName } = this.socket.data;
if (sessionId && userId && userName) {
/**
* Set session and send online count
*/
this.sessionStore
.saveSession(sessionId, { userId, userName, connected: true })
.then(() => this.sessionStore.getOnlineSessionsCount())
.then(online => this.sendOnlineCount(online));
}
}
public async setUserOffline() {
if (this.socket.data.userName && this.socket.data.sessionId && this.socket.data.userId) {
const matchingSockets = await this.io.in(this.socket.data.userId).allSockets();
const isDisconnected = matchingSockets.size === 0;
if (isDisconnected) {
const { sessionId, userId, userName } = this.socket.data;
/**
* Update connection flag of socket to flase and send online count
*/
this.sessionStore
.saveSession(sessionId, { userId, userName, connected: false })
.then(() => this.sessionStore.getOnlineSessionsCount())
.then(online => this.socket.broadcast.emit('user:online', online));
}
}
}
/**
* On socket connected
*/
public async onConnected(socket: Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>) {
/**
* Set user online on connected
*/
this.setUserOnline();
/**
* Broadcast to all clients about new connection
*/
if (this.socket.data.userName && this.socket.data.sessionId && this.socket.data.userId) {
socket.broadcast.emit('user:connected', { userId: this.socket.data.userId, userName: this.socket.data.userName });
}
/**
* Watch status of user and set session
*/
socket.on('user:status', () => this.setUserOnline());
}
/**
* On socket disconnected
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onDisconnected(_reason: string) {
this.setUserOffline();
}
}

View File

@ -1,16 +1,22 @@
enum ColorEnum { enum ColorEnum {
Beige = '#F5F5DC',
IndianRed = '#CD5C5C', IndianRed = '#CD5C5C',
FireBrick = '#B22222', FireBrick = '#B22222',
Pink = '#FFC0CB',
Tomato = '#FF6347', Tomato = '#FF6347',
Orange = '#FFA500', Orange = '#FFA500',
Gold = '#FFD700', Gold = '#FFD700',
Moccasin = '#FFE4B5',
Khaki = '#F0E68C',
Purple = '#800080', Purple = '#800080',
Indigo = '#4B0082', Indigo = '#4B0082',
Teal = '#008080', Teal = '#008080',
Cyan = '#0BC5EA',
Crimson = '#DC143C',
Chocolate = '#D2691E',
Salmon = '#FA8072',
Violet = '#EE82EE',
Blue = '#0000FF',
SteelBlue = '#4682B4',
RosyBrown = '#BC8F8F',
DarkSlateGray = '#2F4F4F',
DimGray = '#696969',
MediumVioletRed = '#C71585', MediumVioletRed = '#C71585',
MediumOrchid = '#BA55D3', MediumOrchid = '#BA55D3',
MediumPurple = '#9370DB', MediumPurple = '#9370DB',

View File

@ -0,0 +1,14 @@
export default class CookieHelper {
public static parseCookieString(string: string): { [key: string]: string } {
const values = string
.split(';')
.map(v => v.split('='))
.reduce((acc, value) => {
const [key, ...rest] = value;
acc[decodeURIComponent(key.trim())] = decodeURIComponent(rest.join('=').trim());
return acc;
}, {});
return values;
}
}

View File

@ -0,0 +1,30 @@
import crypto from 'crypto';
export default class OpaqueTokenHelper {
/**
* Parse token
*
* @param token token
* @returns Hashed token with id
*/
public static parseToken(token: string) {
const parts = token.split('.');
if (parts.length !== 2) {
throw new Error('E_INVALID_API_TOKEN');
}
const id = Buffer.from(parts[0], 'base64').toString('utf-8');
if (!id) {
throw new Error('E_INVALID_API_TOKEN');
}
const hashed = crypto.createHash('sha256').update(parts[1]).digest('hex');
return {
hashed,
id,
};
}
}

View File

@ -31,8 +31,7 @@ export default class AuthMiddleware {
* driver * driver
*/ */
let guardLastAttempted: string | undefined; let guardLastAttempted: string | undefined;
const authPromises: Promise<boolean>[] = guards.map(guard => auth.use(guard).check()); const authResults: boolean[] = await Promise.all(guards.map(guard => auth.use(guard).check()));
const authResults: boolean[] = await Promise.all(authPromises);
for (let i = 0; i < authResults.length; i += 1) { for (let i = 0; i < authResults.length; i += 1) {
/** /**
@ -54,7 +53,7 @@ export default class AuthMiddleware {
public async handle(ctx: HttpContextContract, next: () => Promise<void>, customGuards: (keyof GuardsList)[]) { public async handle(ctx: HttpContextContract, next: () => Promise<void>, customGuards: (keyof GuardsList)[]) {
/** /**
* Verify token from cookie and set the authorization header * Set the authorization header for token
*/ */
const token = ctx.request.cookie('token'); const token = ctx.request.cookie('token');
if (token) { if (token) {

77
app/Middleware/Ws/Auth.ts Normal file
View File

@ -0,0 +1,77 @@
/* eslint-disable no-param-reassign */
import { nanoid } from 'nanoid';
import Redis from '@ioc:Adonis/Addons/Redis';
import CookieHelper from 'App/Helpers/CookieHelper';
import OpaqueTokenHelper from 'App/Helpers/OpaqueTokenHelper';
import Ws, { ClientToServerEvents, InterServerEvents, ServerToClientEvents, SocketData } from 'App/Services/WsService';
import { Socket } from 'socket.io';
import { ExtendedError } from 'socket.io/dist/namespace';
export default class AuthMiddleware {
/**
* Checks the cookie token in headers and
* validates the opaque token
*
* @param socket
* @param next
* @returns void
*/
public static async authenticate(
socket: Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>,
next: (err?: ExtendedError | undefined) => void
) {
/**
* Parse and validate token from cookie
*/
const cookie = socket.handshake.headers?.cookie;
if (!cookie) {
return next(new Error('SocketErrors.MissingParameter'));
}
const token = CookieHelper.parseCookieString(cookie)?.token;
if (!token) {
return next(new Error('SocketErrors.MissingParameter'));
}
const tokenData = JSON.parse(Buffer.from(token.split('.')[0].split(':')[1], 'base64').toString('utf-8'));
const parsed = OpaqueTokenHelper.parseToken(tokenData.message);
const data = await Redis.get(`api:${parsed.id}`);
if (!data) {
return next(new Error('SocketErrors.BadCredentials'));
}
/**
* Assign existing session to socket data
*/
const sessionId = socket.handshake.auth.sessionId as string | undefined;
if (sessionId) {
const session = await Ws.sessionStore.findSession(sessionId);
if (session) {
socket.data.sessionId = sessionId;
socket.data.userId = session.userId;
socket.data.userName = session.userName;
return next();
}
}
/**
* Parse data from redis string and create new session
*/
const parsedData = JSON.parse(data);
/**
* Set custom data to socket
*/
socket.data.sessionId = nanoid();
socket.data.userId = parsedData.user_id as string;
socket.data.userName = parsedData.userName as string;
return next();
}
}

View File

@ -14,7 +14,7 @@ export default class Category extends BaseModel {
public title: string; public title: string;
@column() @column()
public description: string; public description: string | null;
@column({ serializeAs: null }) @column({ serializeAs: null })
public color_id: number | null; public color_id: number | null;
@ -24,10 +24,10 @@ export default class Category extends BaseModel {
}) })
public color: BelongsTo<typeof Color>; public color: BelongsTo<typeof Color>;
@column.dateTime({ autoCreate: true, serializeAs: null }) @column.dateTime({ autoCreate: true })
public createdAt: DateTime; public createdAt: DateTime;
@column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: null }) @column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime; public updatedAt: DateTime;
/** /**

View File

@ -34,6 +34,9 @@ export default class Course extends BaseModel {
@column() @column()
public description: string; public description: string;
@column()
public education_description: string | null;
@column({ serializeAs: null }) @column({ serializeAs: null })
public teacher_id: string; public teacher_id: string;

View File

@ -17,6 +17,8 @@ import ColorHelper from 'App/Helpers/ColorHelper';
import Course from './Course'; import Course from './Course';
import LessonMaterial from './LessonMaterial'; import LessonMaterial from './LessonMaterial';
import LessonContent from './LessonContent'; import LessonContent from './LessonContent';
import LessonProgress from './LessonProgress';
import LessonVideo from './LessonVideo';
import Color from './Color'; import Color from './Color';
export default class Lesson extends BaseModel { export default class Lesson extends BaseModel {
@ -56,9 +58,19 @@ export default class Lesson extends BaseModel {
}) })
public content: HasOne<typeof LessonContent>; public content: HasOne<typeof LessonContent>;
@hasOne(() => LessonVideo, {
foreignKey: 'lesson_id',
})
public video: HasOne<typeof LessonVideo>;
@hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' }) @hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' })
public materials: HasMany<typeof LessonMaterial>; public materials: HasMany<typeof LessonMaterial>;
@hasOne(() => LessonProgress, {
foreignKey: 'lesson_id',
})
public progress: HasOne<typeof LessonProgress>;
@column.dateTime({ autoCreate: true }) @column.dateTime({ autoCreate: true })
public createdAt: DateTime; public createdAt: DateTime;

View File

@ -11,7 +11,7 @@ export default class LessonContent extends BaseModel {
public lesson_id: string; public lesson_id: string;
@column() @column()
public video_url: string; public body: string;
@belongsTo(() => Lesson, { foreignKey: 'lesson_id' }) @belongsTo(() => Lesson, { foreignKey: 'lesson_id' })
public lesson: BelongsTo<typeof Lesson>; public lesson: BelongsTo<typeof Lesson>;

View File

@ -8,10 +8,10 @@ export default class LessonMaterial extends BaseModel {
public id: number; public id: number;
@column() @column()
public url: string; public name: string;
@column() @column()
public name: string; public size: number;
@column() @column()
public clientName: string; public clientName: string;

View File

@ -0,0 +1,28 @@
/* eslint-disable import/no-cycle */
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm';
import Lesson from './Lesson';
import User from './User';
export default class LessonProgress extends BaseModel {
@column({ isPrimary: true, serializeAs: null })
public id: number;
@column({ serializeAs: null })
public lesson_id: string;
@column({ serializeAs: null })
public user_id: string;
@column()
public is_watched: boolean;
@belongsTo(() => Lesson, {
foreignKey: 'lesson_id',
})
public lesson: BelongsTo<typeof Lesson>;
@belongsTo(() => User, {
foreignKey: 'user_id',
})
public user: BelongsTo<typeof User>;
}

38
app/Models/LessonVideo.ts Normal file
View File

@ -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<typeof Lesson>;
@column.dateTime({ autoCreate: true })
public createdAt: DateTime;
@column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime;
}

View File

@ -7,6 +7,8 @@ import {
beforeSave, beforeSave,
column, column,
computed, computed,
HasMany,
hasMany,
HasOne, HasOne,
hasOne, hasOne,
ManyToMany, ManyToMany,
@ -17,6 +19,7 @@ import { nanoid } from 'nanoid';
import Role from 'App/Models/Role'; import Role from 'App/Models/Role';
import Contact from 'App/Models/Contact'; import Contact from 'App/Models/Contact';
import Course from 'App/Models/Course'; import Course from 'App/Models/Course';
import LessonProgress from './LessonProgress';
export default class User extends BaseModel { export default class User extends BaseModel {
@column({ isPrimary: true }) @column({ isPrimary: true })
@ -62,6 +65,11 @@ export default class User extends BaseModel {
}) })
public likes: ManyToMany<typeof Course>; public likes: ManyToMany<typeof Course>;
@hasMany(() => LessonProgress, {
foreignKey: 'user_id',
})
public lessons_progress: HasMany<typeof LessonProgress>;
@column.dateTime({ autoCreate: true }) @column.dateTime({ autoCreate: true })
public createdAt: DateTime; public createdAt: DateTime;

View File

@ -7,6 +7,7 @@ import Category from 'App/Models/Category';
* Validators * Validators
*/ */
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator'; import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
export default class CategoryRepository { export default class CategoryRepository {
private Category: typeof Category; private Category: typeof Category;
@ -67,4 +68,22 @@ export default class CategoryRepository {
return null; return null;
} }
/**
* Update category by id
*
* @param id Category id
* @param data
* @returns
*/
public async update(id: string | number, data: UpdateCategoryValidator['schema']['props']): Promise<Category | null> {
const category = await this.Category.query().where('id', id).first();
if (category) {
await category.merge(data).save();
return category;
}
return null;
}
} }

View File

@ -49,7 +49,7 @@ export default class CourseRepository {
* @returns List of courses * @returns List of courses
*/ */
public async getAll(params?: FetchCoursesValidator['schema']['props']): Promise<Course[]> { public async getAll(params?: FetchCoursesValidator['schema']['props']): Promise<Course[]> {
const { status, category_id } = params || {}; const { status, category_id, limit } = params || {};
const query = this.Course.query(); const query = this.Course.query();
@ -61,12 +61,17 @@ export default class CourseRepository {
query.andWhere('status', status); query.andWhere('status', status);
} }
if (limit) {
query.limit(limit);
}
const courses = await query const courses = await query
.preload('color') .preload('color')
.preload('category') .preload('category')
.withCount('students') .withCount('students')
.withCount('likes') .withCount('likes')
.withCount('lessons'); .withCount('lessons')
.orderBy('created_at', 'desc');
return courses; return courses;
} }
@ -81,9 +86,12 @@ export default class CourseRepository {
const course = await this.Course.query() const course = await this.Course.query()
.preload('teacher') .preload('teacher')
.preload('color') .preload('color')
.preload('category') .preload('category', q => q.preload('color'))
.preload('lessons', q => q.preload('color').orderBy('display_order', 'asc').withCount('materials')) .preload('lessons', q => q.preload('color').orderBy('display_order', 'asc').withCount('materials'))
.preload('students') .preload('students', q => q.preload('roles').preload('contacts'))
.withCount('likes')
.withCount('lessons')
.withCount('students')
.where('id', id) .where('id', id)
.first(); .first();
@ -153,8 +161,17 @@ export default class CourseRepository {
* @param id Course id * @param id Course id
* @returns List of lessons or null * @returns List of lessons or null
*/ */
public async getLessons(id: string | number): Promise<Lesson[] | null> { public async getLessons(id: string | number, userId: string): Promise<Lesson[] | null> {
const course = await this.Course.query().select('id').preload('lessons').where('id', id).first(); const course = await this.Course.query()
.select('id')
.preload('lessons', q =>
q
.preload('progress', p => p.where('user_id', userId))
.preload('color')
.orderBy('display_order', 'asc')
)
.where('id', id)
.first();
return course && course.lessons; return course && course.lessons;
} }
@ -194,6 +211,7 @@ export default class CourseRepository {
course.title = data.title; course.title = data.title;
course.description = data.description; course.description = data.description;
course.education_description = data.education_description;
course.status = data.status; course.status = data.status;
course.teacher_id = data.teacher_id; course.teacher_id = data.teacher_id;
course.category_id = data.category_id; course.category_id = data.category_id;
@ -255,6 +273,7 @@ export default class CourseRepository {
course.merge({ course.merge({
title: data.title, title: data.title,
description: data.description, description: data.description,
education_description: data.education_description,
teacher_id: data.teacher_id, teacher_id: data.teacher_id,
category_id: data.category_id, category_id: data.category_id,
}); });
@ -269,10 +288,11 @@ export default class CourseRepository {
/** /**
* Load other updated data * Load other updated data
*/ */
course.load('category'); await course.load('category');
course.load('teacher'); await course.load('teacher');
await course.save(); await course.save();
return course; return course;
} }

View File

@ -0,0 +1,23 @@
import LessonProgress from 'App/Models/LessonProgress';
export default class LessonProgressRepository {
private LessonProgress: typeof LessonProgress;
constructor() {
this.LessonProgress = LessonProgress;
}
public async get(user_id: string, lesson_id: string): Promise<LessonProgress | null> {
const progress = await this.LessonProgress.query()
.where('user_id', user_id)
.andWhere('lesson_id', lesson_id)
.first();
return progress;
}
public async create(data: { user_id: string; lesson_id: string; is_watched: boolean }): Promise<LessonProgress> {
const progress = await this.LessonProgress.create(data);
return progress;
}
}

View File

@ -1,4 +1,6 @@
import Drive from '@ioc:Adonis/Core/Drive'; import Drive from '@ioc:Adonis/Core/Drive';
import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser';
import { cuid } from '@ioc:Adonis/Core/Helpers';
/** /**
* Models * Models
@ -6,6 +8,7 @@ import Drive from '@ioc:Adonis/Core/Drive';
import Course from 'App/Models/Course'; import Course from 'App/Models/Course';
import Lesson from 'App/Models/Lesson'; import Lesson from 'App/Models/Lesson';
import LessonMaterial from 'App/Models/LessonMaterial'; import LessonMaterial from 'App/Models/LessonMaterial';
import LessonVideo from 'App/Models/LessonVideo';
/** /**
* Validators * Validators
@ -18,11 +21,14 @@ export default class LessonRepository {
private LessonMaterial: typeof LessonMaterial; private LessonMaterial: typeof LessonMaterial;
private LessonVideo: typeof LessonVideo;
private Drive: typeof Drive; private Drive: typeof Drive;
constructor() { constructor() {
this.Lesson = Lesson; this.Lesson = Lesson;
this.LessonMaterial = LessonMaterial; this.LessonMaterial = LessonMaterial;
this.LessonVideo = LessonVideo;
this.Drive = Drive; this.Drive = Drive;
} }
@ -43,7 +49,7 @@ export default class LessonRepository {
* @returns Lesson or null * @returns Lesson or null
*/ */
public async getById(id: string | number): Promise<Lesson | null> { public async getById(id: string | number): Promise<Lesson | null> {
const lesson = await this.Lesson.query().where('id', id).first(); const lesson = await this.Lesson.query().where('id', id).preload('color').withCount('materials').first();
return lesson; return lesson;
} }
@ -59,16 +65,32 @@ export default class LessonRepository {
} }
/** /**
* Get lesson material * Get lesson material by name
* *
* @param fileName Name of file * @param fileName Name of file
* @returns Lesson material file or null * @returns Lesson material or null
*/ */
public async getMaterialFileByName(fileName: string): Promise<LessonMaterial | null> { public async getMaterialFileByName(fileName: string): Promise<LessonMaterial | null> {
const material = await this.LessonMaterial.query().where('name', fileName).first(); const material = await this.LessonMaterial.query().where('name', fileName).first();
return material; return material;
} }
/**
* Get lesson material by id
*
* @param id Material id
* @returns Lesson material or null
*/
public async getMaterialFileById(id: number): Promise<LessonMaterial | null> {
const material = await this.LessonMaterial.query().where('id', id).first();
return material;
}
public async getVideoFileByName(fileName: string): Promise<LessonVideo | null> {
const video = await this.LessonVideo.query().where('name', fileName).first();
return video;
}
/** /**
* Create lesson * Create lesson
* *
@ -83,62 +105,30 @@ export default class LessonRepository {
description: data.description, description: data.description,
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,
}); });
/**
* Attach lesson to course
*/
await lesson.related('course').associate(course);
/** /**
* Create content * Create content
*/ */
await lesson.related('content').create({ video_url: data.video_url }); await lesson.related('content').create({ body: '' });
/**
* Move video file to disk and save to database
*/
await this.createVideo(lesson, data.video);
/** /**
* Create materials * Create materials
*/ */
if (data.materials) { if (data.materials) {
/** await this.createMaterials(lesson, data.materials);
* Move each file to disk
*/
const files = await Promise.all(
data.materials.map(async file => {
await file.moveToDisk('materials');
return file;
})
);
/**
* Create lesson material
*/
const materials = await Promise.all(
files.map(async file => {
if (file.state === 'moved' && file.fileName && file.extname) {
const url = await this.Drive.getUrl(`materials/${file.fileName}`);
const material = new LessonMaterial();
material.name = file.fileName;
material.clientName = file.clientName;
material.ext = file.extname;
material.url = url;
return material;
}
return null;
})
);
/**
* Save lesson materials to database
*/
await lesson.related('materials').createMany(materials.filter((l): l is LessonMaterial => l !== null));
} }
/** /**
* Load data * Load data
*/ */
await lesson.load(loader => loader.load('content').load('materials')); await lesson.load(loader => loader.load('content').load('materials').load('video').load('progress'));
return lesson; return lesson;
} }
@ -150,19 +140,51 @@ export default class LessonRepository {
* @returns Updated lesson * @returns Updated lesson
*/ */
public async update(id: string | number, data: UpdateLessonValidator['schema']['props']): Promise<Lesson | null> { public async update(id: string | number, data: UpdateLessonValidator['schema']['props']): Promise<Lesson | null> {
const lesson = await this.Lesson.query().where('id', id).first(); const lesson = await this.Lesson.query().preload('video').preload('color').where('id', id).first();
if (lesson) { if (lesson) {
await lesson /**
.merge({ * Update files
course_id: data.course_id, */
title: data.title, lesson.merge({
description: data.description, title: data.title,
}) description: data.description,
.save(); course_id: data.course_id,
});
/**
* Update video
*/
if (data.video) {
/**
* Delete video from drive and database
*/
if (lesson.video) {
await this.Drive.delete(`videos/${lesson.video.name}`);
}
await lesson.related('video').query().delete();
/**
* Upload new video to drive and save to database
*/
await this.createVideo(lesson, data.video);
}
/**
* Update materials
*/
if (data.materials && data.materials !== null) {
await this.deleteMaterials(lesson);
await this.createMaterials(lesson, data.materials);
} else if (data.materials === null) await this.deleteMaterials(lesson);
await lesson.load(loader => loader.load('video').load('materials'));
await lesson.save();
return lesson; return lesson;
} }
return null; return null;
} }
@ -173,17 +195,14 @@ export default class LessonRepository {
* @returns Deleted lesson or null * @returns Deleted lesson or null
*/ */
public async delete(id: string | number): Promise<Lesson | null> { public async delete(id: string | number): Promise<Lesson | null> {
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('color').first();
if (lesson) { if (lesson) {
/** /**
* Delete files from disk * Delete files from disk
*/ */
await Promise.all( await this.Drive.delete(`videos/${lesson.video.name}`);
lesson.materials.map(async file => { await this.deleteMaterials(lesson);
await this.Drive.delete(`materials/${file.name}`);
})
);
/** /**
* Delete lesson from database * Delete lesson from database
@ -194,4 +213,68 @@ export default class LessonRepository {
return null; return null;
} }
// eslint-disable-next-line class-methods-use-this
public async createVideo(lesson: Lesson, video: MultipartFileContract): Promise<void> {
const videoName = `${cuid()}.${video.extname}`;
await video.moveToDisk('videos', { name: videoName });
if (video.state === 'moved') {
await lesson.related('video').create({
name: videoName,
clientName: video.clientName,
ext: video.extname,
size: video.size,
url: `/video/${videoName}`,
});
}
}
// eslint-disable-next-line class-methods-use-this
public async createMaterials(lesson: Lesson, materials: MultipartFileContract[]): Promise<void> {
/**
* Move each file to disk
*/
const files = await Promise.all(
materials.map(async file => {
const name = `${cuid()}.${file.extname}`;
await file.moveToDisk('materials', { name });
return { file, name };
})
);
/**
* Create lesson material
*/
const promises = await Promise.all(
files.map(async ({ file, name }) => {
if (file.state === 'moved' && file.fileName && file.extname) {
const material = new LessonMaterial();
material.name = name;
material.size = file.size;
material.clientName = file.clientName;
material.ext = file.extname;
return material;
}
return null;
})
);
/**
* Save lesson materials to database
*/
await lesson.related('materials').createMany(promises.filter((l): l is LessonMaterial => l !== null));
}
public async deleteMaterials(lesson: Lesson) {
await lesson.load('materials');
await Promise.all(
lesson.materials.map(async material => {
await this.Drive.delete(`materials/${material.name}`);
await material.delete();
})
);
}
} }

View File

@ -30,6 +30,11 @@ export default class UserRepository {
return data; return data;
} }
public async getByIds(ids: Array<string>): Promise<Array<User>> {
const data = await this.User.query().preload('contacts').preload('roles').whereIn('id', ids);
return data;
}
/** /**
* Get user by column * Get user by column
* *
@ -64,7 +69,7 @@ export default class UserRepository {
*/ */
public async getAll(params?: any) { public async getAll(params?: any) {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
const { search, email, login, first_name, last_name, role, page = 1, limit = 10 }: any = params || {}; const { search, email, login, first_name, last_name, role, page, limit }: any = params || {};
const query = this.User.query(); const query = this.User.query();
@ -97,8 +102,21 @@ export default class UserRepository {
query.whereHas('roles', q => q.where('slug', role)); query.whereHas('roles', q => q.where('slug', role));
} }
const data = await query.preload('contacts').preload('roles').orderBy('created_at', 'desc').paginate(page, limit); query.preload('contacts').preload('roles').orderBy('created_at', 'desc');
return data.toJSON();
/**
* Return paginated users if provided params
*/
if (limit && page) {
const data = await query.paginate(page, limit);
return data.toJSON();
}
/**
* Return all users
*/
const data = await query;
return data;
} }
/** /**

View File

@ -1,7 +1,6 @@
import { inject, Ioc } from '@adonisjs/core/build/standalone'; import { inject, Ioc } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import Hash from '@ioc:Adonis/Core/Hash'; import Hash from '@ioc:Adonis/Core/Hash';
import Logger from '@ioc:Adonis/Core/Logger';
/** /**
* Datatypes * Datatypes
@ -68,17 +67,19 @@ export default class AuthService {
const token = await ctx.auth.use(this.authGuard).generate(user, { const token = await ctx.auth.use(this.authGuard).generate(user, {
expiresIn: '1d', expiresIn: '1d',
userRoles: user.roles.map(role => role.slug), userRoles: user.roles.map(role => role.slug),
userName: user.fullname,
}); });
ctx.response.cookie('token', token.token); ctx.response.cookie('token', token.token);
Logger.info(`A token for user with id: "${token.user.id}" was successfully generated.`);
return { return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,
message: 'Login success.', message: 'Login success.',
data: token.toJSON(), data: {
userId: user.id,
...token.toJSON(),
},
}; };
} }

View File

@ -15,6 +15,7 @@ import CategoryRepository from 'App/Repositories/CategoryRepository';
* Validators * Validators
*/ */
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator'; import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
@inject() @inject()
export default class CategoryService { export default class CategoryService {
@ -114,6 +115,38 @@ export default class CategoryService {
data, data,
}; };
} }
/**
* Update category
*
* @param id Category id
* @returns Updated category
*/
public async updateCategory(
id: string | number,
data: UpdateCategoryValidator['schema']['props']
): Promise<IResponse> {
const category = await this.categoryRepository.update(id, data);
if (!category) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
message: 'Category not found.',
data: {},
error: {
code: 'E_NOT_FOUND',
},
};
}
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Category updated.',
data: category,
};
}
} }
new Ioc().make(CategoryService); new Ioc().make(CategoryService);

View File

@ -1,5 +1,6 @@
import { inject, Ioc } from '@adonisjs/core/build/standalone'; import { inject, Ioc } from '@adonisjs/core/build/standalone';
import { AuthContract } from '@ioc:Adonis/Addons/Auth'; import { AuthContract } from '@ioc:Adonis/Addons/Auth';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
/** /**
* Datatypes * Datatypes
@ -23,6 +24,7 @@ import CategoryRepository from 'App/Repositories/CategoryRepository';
import CreateCourseValidator from 'App/Validators/Course/CreateCourseValidator'; 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';
@inject() @inject()
export default class CourseService { export default class CourseService {
@ -64,7 +66,7 @@ export default class CourseService {
* @param id Course id * @param id Course id
* @returns Response * @returns Response
*/ */
public async fetchCourse(id: string | number): Promise<IResponse> { public async fetchCourse(id: string | number): Promise<IResponse<Course | {}>> {
const data = await this.courseRepository.getById(id); const data = await this.courseRepository.getById(id);
if (!data) { if (!data) {
@ -151,8 +153,9 @@ export default class CourseService {
* @param id Course id * @param id Course id
* @returns Response * @returns Response
*/ */
public async fetchCourseLessons(id: string | number): Promise<IResponse> { public async fetchCourseLessons(id: string | number, ctx: HttpContextContract): Promise<IResponse> {
const data = await this.courseRepository.getLessons(id); const user = await ctx.auth.use('api').authenticate();
const data = await this.courseRepository.getLessons(id, user.id);
if (!data) { if (!data) {
return { return {
@ -420,6 +423,93 @@ export default class CourseService {
}; };
} }
/**
* Attach list of students from course
*
* @param id Course id
* @param ids Student ids
* @returns Response
*/
public async attachStudentList(id: string | number, ids: Array<string>): Promise<IResponse> {
const course = await this.courseRepository.getById(id);
if (!course) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
message: 'Course not found.',
data: {},
error: {
code: 'E_NOT_FOUND',
},
};
}
const students = await this.userRepository.getByIds(ids);
try {
await course.related('students').attach(students.map(s => s.id));
} catch (error) {
if (error.code === '23505') {
return {
success: false,
status: HttpStatusEnum.BAD_REQUEST,
message: 'Some student already attached to that course.',
data: {},
error: {
code: 'E_BAD_REQUEST',
},
};
}
throw new Error(error);
}
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Student list successfully attached.',
data: {},
};
}
/**
* Detach list of students from course
*
* @param id Course id
* @param ids Student ids
* @returns Response
*/
public async detachStudentList(id: string | number, ids: Array<string>): Promise<IResponse> {
const course = await this.courseRepository.getById(id);
if (!course) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
message: 'Course not found.',
data: {},
error: {
code: 'E_NOT_FOUND',
},
};
}
const students = await this.userRepository.getByIds(ids);
try {
await course.related('students').detach(students.map(s => s.id));
} catch (error) {
throw new Error(error);
}
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Student list successfully detached from course.',
data: {},
};
}
public async detachUserCourse(id: string | number, studentId: string | number): Promise<IResponse> { public async detachUserCourse(id: string | number, studentId: string | number): Promise<IResponse> {
const course = await this.courseRepository.getById(id); const course = await this.courseRepository.getById(id);

View File

@ -8,11 +8,13 @@ import Database from '@ioc:Adonis/Lucid/Database';
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum'; import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
import IResponse from 'App/Datatypes/Interfaces/IResponse'; import IResponse from 'App/Datatypes/Interfaces/IResponse';
import LessonMaterial from 'App/Models/LessonMaterial'; import LessonMaterial from 'App/Models/LessonMaterial';
import LessonVideo from 'App/Models/LessonVideo';
/** /**
* Repositories * Repositories
*/ */
import CourseRepository from 'App/Repositories/CourseRepository'; import CourseRepository from 'App/Repositories/CourseRepository';
import LessonProgressRepository from 'App/Repositories/LessonProgressRepository';
import LessonRepository from 'App/Repositories/LessonRepository'; import LessonRepository from 'App/Repositories/LessonRepository';
/** /**
@ -27,9 +29,16 @@ export default class LessonService {
private lessonRepository: LessonRepository; private lessonRepository: LessonRepository;
constructor(courseRepository: CourseRepository, lessonRepository: LessonRepository) { private lessonProgressRepository: LessonProgressRepository;
constructor(
courseRepository: CourseRepository,
lessonRepository: LessonRepository,
lessonProgressRepository: LessonProgressRepository
) {
this.courseRepository = courseRepository; this.courseRepository = courseRepository;
this.lessonRepository = lessonRepository; this.lessonRepository = lessonRepository;
this.lessonProgressRepository = lessonProgressRepository;
} }
/** /**
@ -54,7 +63,7 @@ export default class LessonService {
* @param id Lesson id * @param id Lesson id
* @returns Response * @returns Response
*/ */
public async fetchLesson(id: string | number): Promise<IResponse> { public async fetchLesson(id: string | number, ctx: HttpContextContract) {
const lesson = await this.lessonRepository.getById(id); const lesson = await this.lessonRepository.getById(id);
if (!lesson) { if (!lesson) {
@ -69,6 +78,26 @@ export default class LessonService {
}; };
} }
/**
* Allow user to view lesson content
*/
if (await ctx.bouncer.denies('viewLessonContent', lesson)) {
return {
success: false,
status: HttpStatusEnum.FORBIDDEN,
message: 'The user is not a student of this course.',
data: {},
error: {
code: 'E_FORBIDDEN',
},
};
}
/**
* Load lesson with materials
*/
await lesson.load(loader => loader.load('content').load('materials').load('video'));
return { return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,
@ -83,8 +112,8 @@ export default class LessonService {
* @param fileName Name of file * @param fileName Name of file
* @returns Response * @returns Response
*/ */
public async fetchMaterialFile(ctx: HttpContextContract, fileName: string): Promise<IResponse<LessonMaterial>> { public async fetchMaterialFile(ctx: HttpContextContract, name: string): Promise<IResponse<LessonMaterial>> {
const material = await this.lessonRepository.getMaterialFileByName(fileName); const material = await this.lessonRepository.getMaterialFileByName(name);
if (!material) { if (!material) {
return { return {
@ -123,6 +152,93 @@ export default class LessonService {
}; };
} }
public async fetchVideoFile(ctx: HttpContextContract, name: string): Promise<IResponse<LessonVideo>> {
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
*
* @param id Lesson id
* @param ctx Http context
*/
public async fetchLessonProgress(id: string, ctx: HttpContextContract): Promise<IResponse> {
const user = await ctx.auth.use('api').authenticate();
const lesson = await this.lessonRepository.getById(id);
if (!lesson) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
message: 'Lesson not found.',
data: {},
error: {
code: 'E_NOT_FOUND',
},
};
}
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);
if (!progress) {
await this.lessonProgressRepository.create({ user_id: user.id, lesson_id: lesson.id, is_watched: true });
}
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Fetched lesson progress',
data: {
progress,
},
};
}
/** /**
* Create lesson * Create lesson
* *
@ -144,6 +260,9 @@ export default class LessonService {
}; };
} }
/**
* Create new lesson
*/
const lesson = await this.lessonRepository.create(course, data); const lesson = await this.lessonRepository.create(course, data);
return { return {

View File

@ -37,7 +37,10 @@ export default class MeService {
const user = await auth.use(this.authGuard).authenticate(); const user = await auth.use(this.authGuard).authenticate();
await user.load(loader => { await user.load(loader => {
loader.load('roles').load('contacts'); loader
.load('roles')
.load('contacts')
.load('likes', q => q.select('id'));
}); });
/** /**

View File

@ -0,0 +1,37 @@
import { inject, Ioc } from '@adonisjs/core/build/standalone';
import IResponse from 'App/Datatypes/Interfaces/IResponse';
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
import Database from '@ioc:Adonis/Lucid/Database';
import Ws from './WsService';
@inject()
export default class StatService {
/**
* Get basic statistics about the system
*
* @returns Response
*/
// eslint-disable-next-line class-methods-use-this
public async fetchStatData(): Promise<IResponse> {
const count = await Database.rawQuery(
`
select (select count(*) from courses) as courses_count,
(select count(*) from lessons) as lessons_count
`
);
const online = await Ws.sessionStore.getOnlineSessionsCount();
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Fetched stat.',
data: {
...count.rows[0],
online,
},
};
}
}
new Ioc().make(StatService);

View File

@ -76,16 +76,24 @@ export default class UserService {
*/ */
public async fetchUsers(params?: any): Promise<IResponse> { public async fetchUsers(params?: any): Promise<IResponse> {
const users = await this.userRepository.getAll(params); const users = await this.userRepository.getAll(params);
const result: IResponse = {
return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,
message: 'Fetched all users.', message: 'Fetched all users.',
data: users.data, data: {},
meta: {
pagination: users.meta,
},
}; };
if (users instanceof Array) {
result.data = users;
return result;
}
result.data = users.data;
result.meta = {
pagination: users.meta,
};
return result;
} }
/** /**

95
app/Services/WsService.ts Normal file
View File

@ -0,0 +1,95 @@
import { Server } from 'socket.io';
import Redis from '@ioc:Adonis/Addons/Redis';
import AdonisServer from '@ioc:Adonis/Core/Server';
/**
* Controllers
*/
import OnlineController from 'App/Controllers/Ws/OnlineController';
/**
* Middlewares
*/
import AuthMiddleware from 'App/Middleware/Ws/Auth';
import RedisSessionStore from 'App/Store/SessionStore';
export interface ServerToClientEvents {
/**
* User
*/
'user:session': (data: { sessionId: string | undefined; userId: string | undefined }) => void;
'user:connected': (data: { userId: string; userName: string }) => void;
'user:online': (data: number) => void;
}
export interface ClientToServerEvents {
/**
* User
*/
'user:logout': () => void;
'user:status': () => void;
}
export interface InterServerEvents {}
export interface SocketData {
sessionId: string;
userId: string;
userName: string;
}
class WsService {
public io: Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
public sessionStore: RedisSessionStore;
public booted = false;
public boot() {
if (this.booted) {
return;
}
this.booted = true;
this.io = new Server(AdonisServer.instance!, {
cors: {
origin: 'http://localhost:3000',
credentials: true,
},
});
this.sessionStore = new RedisSessionStore(Redis.connection('session'));
this.setupMiddlewares();
this.listen();
}
private listen() {
this.io.on('connection', socket => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const onlineController = new OnlineController(socket, this.io, this.sessionStore);
const { sessionId, userId, userName } = socket.data;
const isExistSocketData = !!(sessionId && userId && userName);
/**
* Send session to client
*/
socket.emit('user:session', { sessionId: socket.data.sessionId, userId: socket.data.userId });
/**
* Destroy session on user logout request
*/
socket.on('user:logout', async () => {
if (isExistSocketData) {
await this.sessionStore.destroySession(sessionId);
}
});
});
}
private setupMiddlewares() {
this.io.use(AuthMiddleware.authenticate);
}
}
export default new WsService();

108
app/Store/SessionStore.ts Normal file
View File

@ -0,0 +1,108 @@
/* eslint-disable max-classes-per-file */
import { RedisConnectionContract } from '@ioc:Adonis/Addons/Redis';
type SessionDataType = { userId: string; userName: string; connected: boolean };
const SESSION_TTL = 24 * 60 * 60; // 1 day
class RedisSessionStore {
public redisClient: RedisConnectionContract;
constructor(redisClient: RedisConnectionContract) {
this.redisClient = redisClient;
}
// eslint-disable-next-line class-methods-use-this
private transform(values: (string | null)[]) {
const [userId, userName, connected] = values;
if (!userId || !userName || !connected) return undefined;
return {
userId,
userName,
connected: connected === 'true',
};
}
/**
* Return session data by session id
*
* @param id Session id
* @returns Session data
*/
public async findSession(id: string): Promise<SessionDataType | undefined> {
const values = await this.redisClient.hmget(id, 'userId', 'userName', 'connected');
return this.transform(values);
}
/**
* Returns the all sessions from store
*/
public async getSessions(): Promise<Array<SessionDataType | undefined>> {
const keys = new Set<string>();
let nextIndex = 0;
do {
// eslint-disable-next-line no-await-in-loop
const [nextIndexAsStr, result] = await this.redisClient.scan(nextIndex, 'MATCH', '*', 'COUNT', 100);
nextIndex = parseInt(nextIndexAsStr, 10);
result.forEach(s => keys.add(s));
} while (nextIndex !== 0);
const commands = Array.from(keys).map(key => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [prefix, ...id] = key.split('-');
return ['hmget', id.join('-'), 'userId', 'userName', 'connected'];
});
return this.redisClient
.multi(commands)
.exec()
.then(results => results.map(([err, session]) => (err ? undefined : this.transform(session))));
}
/**
* Returns the unique sessions with connected flag `true`
*/
public async getOnlineSessionsCount() {
const sessions = await this.getSessions();
const online = new Set(sessions.filter(s => s?.connected === true).map(s => s?.userId));
return online.size;
}
/**
* Set session data to redis store
*
* @param id Session id
* @param data Session data
*/
public async saveSession(id: string, data: SessionDataType): Promise<void> {
await this.redisClient
.multi()
.hmset(id, {
userId: data.userId,
userName: data.userName,
connected: data.connected,
})
.expire(id, SESSION_TTL)
.exec();
}
/**
* Remove session from redis store
*
* @param id Session id
*/
public async destroySession(id: string): Promise<number> {
const session = await this.findSession(id);
if (session) {
return this.redisClient.del(id);
}
return 0;
}
}
export default RedisSessionStore;

View File

@ -6,7 +6,7 @@ export default class CreateCategoryValidator {
public schema = schema.create({ public schema = schema.create({
title: schema.string(), title: schema.string(),
description: schema.string(), description: schema.string.optional(),
}); });
public messages = {}; public messages = {};

View File

@ -0,0 +1,13 @@
import { schema } from '@ioc:Adonis/Core/Validator';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
export default class UpdateCategoryValidator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({
title: schema.string.optional(),
description: schema.string.nullableAndOptional(),
});
public messages = {};
}

View File

@ -5,15 +5,15 @@ export default class CreateContactsValidator {
constructor(protected ctx: HttpContextContract) {} constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({ public schema = schema.create({
phone_number: schema.string.optional({}, [ phone_number: schema.string.nullableAndOptional({}, [
rules.mobile({ rules.mobile({
locales: ['ru-RU', 'en-US'], locales: ['ru-RU', 'en-US'],
strict: true, strict: true,
}), }),
]), ]),
vk_id: schema.string.optional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]), vk_id: schema.string.nullableAndOptional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]),
twitter_id: schema.string.optional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]), twitter_id: schema.string.nullableAndOptional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]),
telegram_id: schema.string.optional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]), telegram_id: schema.string.nullableAndOptional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]),
}); });
public messages = {}; public messages = {};

View File

@ -0,0 +1,23 @@
import { schema, rules } from '@ioc:Adonis/Core/Validator';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
export default class AttachStudentsValitator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({
students: schema.array().members(
schema.string({}, [
rules.exists({ table: 'users', column: 'id' }),
rules.unique({
table: 'users_courses',
column: 'user_id',
where: {
course_id: this.ctx.params.id,
},
}),
])
),
});
public messages = {};
}

View File

@ -8,6 +8,7 @@ export default class CreateCourseValidator {
public schema = schema.create({ public schema = schema.create({
title: schema.string(), title: schema.string(),
description: schema.string(), description: schema.string(),
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'],

View File

@ -0,0 +1,12 @@
import { schema, rules } from '@ioc:Adonis/Core/Validator';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
export default class DetachStudentstValitator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({
students: schema.array().members(schema.string({}, [rules.exists({ table: 'users', column: 'id' })])),
});
public messages = {};
}

View File

@ -8,6 +8,7 @@ export default class FetchCoursesValidator {
public schema = schema.create({ public schema = schema.create({
status: schema.enum.optional(Object.values(CourseStatusEnum)), status: schema.enum.optional(Object.values(CourseStatusEnum)),
category_id: schema.string.optional({}, [rules.exists({ table: 'categories', column: 'id' })]), category_id: schema.string.optional({}, [rules.exists({ table: 'categories', column: 'id' })]),
limit: schema.number.optional(),
}); });
public messages = {}; public messages = {};

View File

@ -7,6 +7,7 @@ export default class UpdateCourseValidator {
public schema = schema.create({ public schema = schema.create({
title: schema.string.optional(), title: schema.string.optional(),
description: schema.string.optional(), description: schema.string.optional(),
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'],

View File

@ -9,10 +9,13 @@ 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_url: schema.string({}, [rules.url()]), video: schema.file({
size: '5000mb',
extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'],
}),
materials: schema.array.optional().members( materials: schema.array.optional().members(
schema.file({ schema.file({
size: '10mb', size: '100mb',
extnames: ['pdf', 'zip', 'rar', 'doc', 'docx'], extnames: ['pdf', 'zip', 'rar', 'doc', 'docx'],
}) })
), ),

View File

@ -5,9 +5,20 @@ export default class UpdateLessonValidator {
constructor(protected ctx: HttpContextContract) {} constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({ public schema = schema.create({
course_id: schema.string({}, [rules.exists({ table: 'courses', column: 'id' })]), course_id: schema.string.optional({}, [rules.exists({ table: 'courses', column: 'id' })]),
title: schema.string({}, [rules.maxLength(255)]), title: schema.string.optional({}, [rules.maxLength(255)]),
description: schema.string(), description: schema.string.optional(),
duration: schema.date.optional({ format: 'HH:mm:ss' }),
video: schema.file.optional({
size: '5000mb',
extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'],
}),
materials: schema.array.nullableAndOptional().members(
schema.file({
size: '100mb',
extnames: ['pdf', 'zip', 'rar', 'doc', 'docx'],
})
),
}); });
public messages = {}; public messages = {};

View File

@ -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<any>;
/**
* 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<any> {
return validator.validate({
schema: this.schemas(type),
data,
...(this.errorMessages[type] ? { messages: this.errorMessages[type] } : {}),
...(this.cacheKey ? { cacheKey: this.cacheKey } : {}),
});
}
}

View File

@ -50,7 +50,7 @@ const databaseConfig: DatabaseConfig = {
paths: ['./database/seeders/MainSeeder'], paths: ['./database/seeders/MainSeeder'],
}, },
healthCheck: true, healthCheck: true,
debug: true, debug: false,
}, },
}, },
}; };

View File

@ -93,15 +93,15 @@ const driveConfig: DriveConfig = {
|************************************************************************** |**************************************************************************
| |
*/ */
// s3: { s3: {
// driver: 's3', driver: 's3',
// visibility: 'public', visibility: 'private',
// key: Env.get('S3_KEY'), key: Env.get('S3_KEY'),
// secret: Env.get('S3_SECRET'), secret: Env.get('S3_SECRET'),
// region: Env.get('S3_REGION'), region: Env.get('S3_REGION'),
// bucket: Env.get('S3_BUCKET'), bucket: Env.get('S3_BUCKET'),
// endpoint: Env.get('S3_ENDPOINT'), endpoint: Env.get('S3_ENDPOINT'),
// }, },
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -43,6 +43,14 @@ const redisConfig: RedisConfig = {
keyPrefix: '', keyPrefix: '',
healthCheck: true, healthCheck: true,
}, },
session: {
host: Env.get('REDIS_HOST'),
port: Env.get('REDIS_PORT'),
password: Env.get('REDIS_PASSWORD', ''),
db: 1,
keyPrefix: 'session-',
healthCheck: true,
},
}, },
}; };

View File

@ -4,10 +4,10 @@ declare module '@ioc:Adonis/Core/Drive' {
config: LocalDriverConfig; config: LocalDriverConfig;
implementation: LocalDriverContract; implementation: LocalDriverContract;
}; };
// s3: { s3: {
// config: S3DriverConfig config: S3DriverConfig;
// implementation: S3DriverContract implementation: S3DriverContract;
// } };
// gcs: { // gcs: {
// config: GcsDriverConfig // config: GcsDriverConfig
// implementation: GcsDriverContract // implementation: GcsDriverContract

View File

@ -8,5 +8,6 @@
declare module '@ioc:Adonis/Addons/Redis' { declare module '@ioc:Adonis/Addons/Redis' {
interface RedisConnectionsList { interface RedisConnectionsList {
local: RedisConnectionConfig; local: RedisConnectionConfig;
session: RedisConnectionConfig;
} }
} }

View File

@ -56,7 +56,7 @@ export const StudentFactory = Factory.define(User, ({ faker }) => makeFakeUser(f
export const LessonContentFactory = Factory.define(LessonContent, ({ faker }) => { export const LessonContentFactory = Factory.define(LessonContent, ({ faker }) => {
return { return {
video_url: `https://www.youtube.com/embed/${faker.datatype.string(10)}`, body: `${faker.lorem.sentences(40)}`,
}; };
}).build(); }).build();
@ -87,6 +87,7 @@ export const CourseFactory = Factory.define(Course, ({ faker }) => {
return { return {
title: faker.lorem.sentence(8), title: faker.lorem.sentence(8),
description: faker.lorem.sentence(20), description: faker.lorem.sentence(20),
education_description: faker.lorem.sentence(15),
status: CourseStatusEnum.PUBLISHED, status: CourseStatusEnum.PUBLISHED,
}; };
}) })

View File

@ -14,6 +14,7 @@ export default class Courses extends BaseSchema {
table.json('image'); table.json('image');
table.string('title').notNullable(); table.string('title').notNullable();
table.string('description').notNullable(); table.string('description').notNullable();
table.string('education_description', 250);
table.string('teacher_id', 21).unsigned().references('users.id').notNullable().onDelete('CASCADE'); table.string('teacher_id', 21).unsigned().references('users.id').notNullable().onDelete('CASCADE');
table.string('category_id', 21).unsigned().references('categories.id').notNullable().onDelete('CASCADE'); table.string('category_id', 21).unsigned().references('categories.id').notNullable().onDelete('CASCADE');
table.integer('color_id').unsigned().references('colors.id'); table.integer('color_id').unsigned().references('colors.id');

View File

@ -7,7 +7,7 @@ export default class LessonContents extends BaseSchema {
this.schema.createTable(this.tableName, table => { this.schema.createTable(this.tableName, table => {
table.increments('id').primary(); table.increments('id').primary();
table.string('lesson_id', 21).unsigned().references('lessons.id').unique().onDelete('CASCADE'); table.string('lesson_id', 21).unsigned().references('lessons.id').unique().onDelete('CASCADE');
table.string('video_url').notNullable(); table.text('body');
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

@ -6,8 +6,8 @@ export default class LessonMaterials extends BaseSchema {
public async up() { public async up() {
this.schema.createTable(this.tableName, table => { this.schema.createTable(this.tableName, table => {
table.increments('id').primary(); table.increments('id').primary();
table.string('url').notNullable();
table.string('name').notNullable(); table.string('name').notNullable();
table.integer('size').notNullable();
table.string('client_name').notNullable(); table.string('client_name').notNullable();
table.string('ext').notNullable(); table.string('ext').notNullable();
table.string('lesson_id', 21).unsigned().references('lessons.id').onDelete('CASCADE'); table.string('lesson_id', 21).unsigned().references('lessons.id').onDelete('CASCADE');

View File

@ -0,0 +1,19 @@
import BaseSchema from '@ioc:Adonis/Lucid/Schema';
export default class LessonProgresses extends BaseSchema {
protected tableName = 'lesson_progresses';
public async up() {
this.schema.createTable(this.tableName, table => {
table.increments('id');
table.string('user_id', 21).unsigned().references('users.id').onDelete('CASCADE');
table.string('lesson_id', 21).unsigned().references('lessons.id').onDelete('CASCADE');
table.boolean('is_watched').notNullable();
table.unique(['user_id', 'lesson_id']);
});
}
public async down() {
this.schema.dropTable(this.tableName);
}
}

View File

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

9
env.ts
View File

@ -20,13 +20,13 @@ export default Env.rules({
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', 'testing'] as const),
DRIVE_DISK: Env.schema.enum(['local'] 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(),
PG_USER: Env.schema.string(), PG_USER: Env.schema.string(),
PG_PASSWORD: Env.schema.string.optional(), PG_PASSWORD: Env.schema.string.optional(),
PG_DB_NAME: Env.schema.string(), PG_DB_NAME: Env.schema.string(),
REDIS_CONNECTION: Env.schema.enum(['local'] as const), REDIS_CONNECTION: Env.schema.enum(['local', 'session'] as const),
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(),
@ -36,4 +36,9 @@ export default Env.rules({
SMTP_PASSWORD: Env.schema.string(), SMTP_PASSWORD: Env.schema.string(),
MAILGUN_API_KEY: Env.schema.string(), MAILGUN_API_KEY: Env.schema.string(),
MAILGUN_DOMAIN: Env.schema.string(), MAILGUN_DOMAIN: Env.schema.string(),
S3_KEY: Env.schema.string(),
S3_SECRET: Env.schema.string(),
S3_BUCKET: Env.schema.string(),
S3_REGION: Env.schema.string(),
S3_ENDPOINT: Env.schema.string.optional(),
}); });

1076
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.0.1", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node ace build --production", "build": "node ace build --production",
@ -37,11 +37,13 @@
"@adonisjs/bouncer": "^2.2.5", "@adonisjs/bouncer": "^2.2.5",
"@adonisjs/core": "^5.4.2", "@adonisjs/core": "^5.4.2",
"@adonisjs/drive": "^2.0.7", "@adonisjs/drive": "^2.0.7",
"@adonisjs/drive-s3": "^1.0.5",
"@adonisjs/lucid": "^16.3.2", "@adonisjs/lucid": "^16.3.2",
"@adonisjs/mail": "^7.2.4", "@adonisjs/mail": "^7.2.4",
"@adonisjs/redis": "^7.0.9", "@adonisjs/redis": "^7.0.9",
"@adonisjs/repl": "^3.1.7", "@adonisjs/repl": "^3.1.7",
"@adonisjs/view": "^6.1.1", "@adonisjs/view": "^6.1.1",
"@types/socket.io": "^3.0.2",
"cloudinary": "^1.26.3", "cloudinary": "^1.26.3",
"luxon": "^1.28.0", "luxon": "^1.28.0",
"nanoid": "^3.1.25", "nanoid": "^3.1.25",
@ -49,6 +51,7 @@
"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",
"source-map-support": "^0.5.19" "source-map-support": "^0.5.19"
} }
} }

View File

@ -7,7 +7,11 @@ export default class AppProvider {
public async boot() {} public async boot() {}
public async ready() {} public async ready() {
if (this.app.environment === 'web') {
await import('../start/socket');
}
}
public async shutdown() {} public async shutdown() {}
} }

View File

@ -5,4 +5,5 @@ Route.group(() => {
Route.get('/:id', 'Api/v1/CategoriesController.show').middleware('role:admin,teacher,student').as('categories.show'); Route.get('/:id', 'Api/v1/CategoriesController.show').middleware('role:admin,teacher,student').as('categories.show');
Route.post('/', 'Api/v1/CategoriesController.create').middleware('role:admin,teacher').as('categories.create'); Route.post('/', 'Api/v1/CategoriesController.create').middleware('role:admin,teacher').as('categories.create');
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');
}).prefix('categories'); }).prefix('categories');

View File

@ -30,6 +30,14 @@ Route.group(() => {
.middleware('role:admin,teacher') .middleware('role:admin,teacher')
.as('courses.attach-student'); .as('courses.attach-student');
Route.post('/:id/attach-student-list', 'Api/v1/CoursesController.attachStudentList')
.middleware('role:admin,teacher')
.as('courses.attach-student-list');
Route.patch('/:id/detach-student-list', 'Api/v1/CoursesController.detachStudentList')
.middleware('role:admin,teacher')
.as('courses.detach-student-list');
Route.delete('/:id/detach-student', 'Api/v1/CoursesController.detachStudent') Route.delete('/:id/detach-student', 'Api/v1/CoursesController.detachStudent')
.middleware('role:admin,teacher') .middleware('role:admin,teacher')
.as('courses.detach-student'); .as('courses.detach-student');
@ -49,4 +57,8 @@ Route.group(() => {
Route.post('/:id/set-status', 'Api/v1/CoursesController.setStatus') Route.post('/:id/set-status', 'Api/v1/CoursesController.setStatus')
.middleware('role:admin,teacher') .middleware('role:admin,teacher')
.as('courses.set-status'); .as('courses.set-status');
Route.get('/:id/lessons-progress', 'Api/v1/CoursesController.getLessonsProgress')
.middleware('role:admin,teacher,student')
.as('courses.get-lessons-progress');
}).prefix('courses'); }).prefix('courses');

View File

@ -63,6 +63,11 @@ Route.group(() => {
* *
*/ */
require('./categories'); require('./categories');
/**
* Stat module
*/
require('./stat');
}).middleware('auth'); }).middleware('auth');
}) })
.prefix('v1') .prefix('v1')

View File

@ -8,11 +8,14 @@ Route.group(() => {
.as('lessons.update-order'); .as('lessons.update-order');
Route.get('/:id', 'Api/v1/LessonsController.show').middleware('role:admin,teacher,student').as('lessons.show'); Route.get('/:id', 'Api/v1/LessonsController.show').middleware('role:admin,teacher,student').as('lessons.show');
Route.delete('/:id', 'Api/v1/LessonsController.delete').middleware('role:admin,teacher').as('lessons.delete'); Route.delete('/:id', 'Api/v1/LessonsController.delete').middleware('role:admin,teacher').as('lessons.delete');
Route.put('/:id', 'Api/v1/LessonsController.update').middleware('role:admin,teacher').as('lessons.update'); Route.patch('/:id', 'Api/v1/LessonsController.update').middleware('role:admin,teacher').as('lessons.update');
Route.get('/:id/content', 'Api/v1/LessonsController.getContent') Route.get('/:id/content', 'Api/v1/LessonsController.getContent')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('lessons.get-content'); .as('lessons.get-content');
Route.get('/materials/:file', 'Api/v1/LessonsController.getMaterial') Route.get('/materials/:fileName', 'Api/v1/LessonsController.getMaterial')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('lessons.get-material'); .as('lessons.get-material');
Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress')
.middleware('role:admin,teacher,student')
.as('lessons.get-video-progress');
}).prefix('lessons'); }).prefix('lessons');

5
routes/apis/v1/stat.ts Normal file
View File

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

View File

@ -26,4 +26,9 @@ Route.group(() => {
* Api routes * Api routes
*/ */
require('./api'); require('./api');
/**
* Other routes
*/
require('./videos');
}); });

7
routes/videos.ts Normal file
View File

@ -0,0 +1,7 @@
import Route from '@ioc:Adonis/Core/Route';
Route.group(() => {
Route.get('/video/:fileName', 'VideosController.getVideo')
.middleware('role:admin,teacher,student')
.as('videos.get-video');
}).middleware('auth');

6
start/socket.ts Normal file
View File

@ -0,0 +1,6 @@
import Ws from 'App/Services/WsService';
/**
* Start websocket server
*/
Ws.boot();

View File

@ -35,7 +35,8 @@
"@adonisjs/mail", "@adonisjs/mail",
"@adonisjs/view", "@adonisjs/view",
"@adonisjs/attachment-lite", "@adonisjs/attachment-lite",
"@adonisjs/bouncer" "@adonisjs/bouncer",
"@adonisjs/drive-s3"
] ]
} }
} }