Compare commits

...

104 Commits

Author SHA1 Message Date
2e90ad2524 Merge tag 'v1.4.5' into develop
Release v1.4.5
2022-06-13 14:09:19 +03:00
135b9faa6b Merge branch 'release/1.4.5' 2022-06-13 14:09:13 +03:00
1b13dd6ce1 bump v 2022-06-13 14:08:58 +03:00
6fe7b9873b disable ssl for db 2022-06-13 14:08:27 +03:00
6091987ba4 Merge tag 'v1.4.4' into develop
Release v1.4.4
2022-06-13 14:04:31 +03:00
fa8270f88e Merge branch 'release/1.4.4' 2022-06-13 14:04:22 +03:00
6b26489f9e bump ver 2022-06-13 14:04:06 +03:00
933c9aa887 del procfile 2022-06-13 14:02:54 +03:00
Sergey Yarkov
7b55d47360
Merge pull request #1 from sergeyyarkov/develop
Merge tag 'v1.4.3' into develop
2022-06-13 13:45:42 +03:00
7ec72a53b6 trust proxy 2022-06-13 13:17:25 +03:00
a3ce94f1de trust proxy 2022-06-13 13:14:40 +03:00
bfd41f391a db ssl 2022-06-13 01:33:41 +03:00
da67d3d4e1 Merge tag 'v1.4.3' into develop
Release v1.4.3
2022-06-13 01:25:24 +03:00
ef9f757c35 Merge branch 'release/1.4.3' 2022-06-13 01:25:15 +03:00
8f9d6e8cab bump version 2022-06-13 01:24:59 +03:00
9134c4d912 Procfile 2022-06-13 01:12:32 +03:00
7bc2db4451 removed host env 2022-06-13 01:02:21 +03:00
8508a6c43e del 2022-06-12 22:11:17 +03:00
5510d9fbaf make it better 2022-06-12 22:05:55 +03:00
92065a2ff5 Merge tag 'v1.4.2' into develop
Release v1.4.2
2022-06-12 20:36:05 +03:00
b088442d47 Merge branch 'release/1.4.2' 2022-06-12 20:35:43 +03:00
b9baca7f7f changelog & bump version 2022-06-12 20:35:33 +03:00
066caa6a9d fix: insert display order in lessons seeder 2022-06-12 20:17:39 +03:00
4c0377d234 feat: upload video lesson via src link 2022-06-12 19:55:13 +03:00
cc36a77912 fix: user upd 2022-06-10 00:36:41 +03:00
0472036b66 refactor: refactored course service 2022-06-10 00:28:11 +03:00
7149a0f3eb fix: remove course files from drive after deleting user or category 2022-06-09 23:50:40 +03:00
30fb10b338 feat: added new users to seeder 2022-06-09 19:02:35 +03:00
b6217a4007 refactor: moved bouncer actions to policies 2022-06-08 23:08:09 +03:00
06e8acd0b2 fix: lesson deleting 2022-06-07 00:51:03 +03:00
4aed828c4e Merge tag 'v1.3.2' into develop
Version 1.3.2
2022-06-07 00:14:15 +03:00
8a8e3cd552 Merge branch 'release/1.3.2' 2022-06-07 00:13:54 +03:00
3f5222eb88 bump version 2022-06-07 00:13:37 +03:00
ff97175fbe fix: fixed ts types 2022-06-06 23:24:25 +03:00
303a27089b fix: remove all lesson file on delete course 2022-06-06 22:47:42 +03:00
22ff69a3b4 changelog 2022-06-06 19:29:02 +03:00
294a4f584d feat: added mailer env 2022-06-05 23:04:15 +03:00
5171f65429 fix: cors & api path 2022-06-04 00:31:46 +03:00
ffdebc4f92 fix: lesson duration field updating 2022-06-02 22:44:12 +03:00
5a01acdb21 Merge tag 'v1.3.1' into develop
Release v1.3.1
2022-04-10 23:23:55 +03:00
59207d8ea4 Merge branch 'release/1.3.1' 2022-04-10 23:23:42 +03:00
9c16ddd9a6 readme 2022-04-10 23:23:28 +03:00
3b37fc3b46 fix: fixed permission denied on create folders 2022-04-07 23:53:09 +03:00
b1965eeeda readme 2022-04-06 23:42:56 +03:00
793d7d650a feat: added client service 2022-03-30 00:02:19 +03:00
416f221066 feat: nginx container 2022-03-26 22:39:12 +03:00
79e56696fd feat: added base docker container 2022-03-10 23:55:04 +03:00
c0aad40853 Merge tag 'v1.2.1' into develop
Release v1.2.1
2022-03-06 23:05:27 +03:00
2b6f89d062 Merge branch 'release/1.2.1' 2022-03-06 23:05:11 +03:00
1934e85c84 changelog 2022-03-06 23:04:32 +03:00
7332d7a0c9 refactor: refactored routes import 2022-03-06 22:54:42 +03:00
a8c2e65bfb fix: fixed error on deleting notification 2022-03-01 22:46:48 +03:00
97ed27a50a feat: added chat controller 2022-03-01 18:10:04 +03:00
c6472013a3 feat: delete notifications 2022-02-28 21:39:34 +03:00
cd811195f1 feat: added history limit to messages store 2022-02-23 20:02:37 +03:00
877e15bcde feat: saving notifications 2022-02-20 23:02:11 +03:00
4056dde970 fix: fixed role check middleware 2022-02-14 21:58:07 +03:00
046e3a28f3 fix: error while updating user info 2022-02-12 22:26:07 +03:00
28fe68aa26 feat: chat history finished 2022-02-12 18:23:20 +03:00
3e16fe6a19 feat: sending message time 2022-02-11 20:18:22 +03:00
971a4b8c21 feat: added first impl of messenger 2022-02-10 22:22:40 +03:00
7b75877bd7 fix: validator message 2022-02-07 00:28:00 +03:00
953283247b refactor: user online 2022-02-06 22:33:38 +03:00
452d7d9557 feat: updating info of authorized user 2022-02-03 22:32:47 +03:00
9dc74376f2 Updating Changelog 2022-02-02 17:18:48 +03:00
a0cba27c48 feat: added last login column & about column to user model 2022-02-02 17:14:52 +03:00
7e81d7b4ff fix: cleaning up sessions 2022-02-01 22:47:36 +03:00
e22c80733a Merge tag 'v1.1.0' into develop
Release 1.1.0
2022-01-31 20:40:08 +03:00
a90dcf65cf Merge branch 'release/1.1.0' 2022-01-31 20:36:40 +03:00
5708d5cb00 changelog 2022-01-31 20:35:14 +03:00
d46c2bb138 refactor: moved some logic to online controller for websocket server 2022-01-31 00:03:48 +03:00
13d75468d9 feat: destroy session on logout user 2022-01-30 20:04:06 +03:00
448d071f93 fix: fixed session saving 2022-01-30 19:08:30 +03:00
d52cee9f57 fix: fixed types 2022-01-30 18:47:40 +03:00
7c678797d7 feat: added ws online counter 2022-01-30 01:40:18 +03:00
aa74a7527f feat: added basic stat info 2022-01-24 22:13:16 +03:00
e3dd4a8d6a feat: added limit on fetching courses 2022-01-23 22:43:36 +03:00
6248134614 fix: fixed category updating 2022-01-22 19:46:58 +03:00
bac861a09d feat: category updating 2022-01-19 23:09:29 +03:00
175150a60a fix: removed unwandet colors 2022-01-18 19:16:42 +03:00
80beed7996 fix: video deleting on update lesson 2022-01-17 23:42:30 +03:00
81e511ed19 feat: lesson updating 2022-01-17 22:50:15 +03:00
cb80f507cc refactor: lesson creating 2022-01-05 19:54:49 +03:00
df08f65a3b feat: added materials uploader 2022-01-05 01:29:18 +03:00
ef3711a367 fix: fixed material downloading 2022-01-02 22:27:15 +03:00
b44f3262b7 feat: added videos controller 2022-01-02 22:19:08 +03:00
cd4eb2513c feat: added lesson video model 2021-12-31 01:14:18 +03:00
5171f460ed feat: added s3 cloud storage support 2021-12-23 23:17:04 +03:00
bb513d8dab feat: added lesson progress 2021-12-22 21:13:42 +03:00
14a89f77b3 feat: added size column to lesson model & return materials to lesson response 2021-12-21 20:50:32 +03:00
11f0d30092 feat: added new fields to course model 2021-12-18 21:07:48 +03:00
a4d8628c81 feat: load contacts & roles on students 2021-12-16 00:01:40 +03:00
e3575caab3 feat: detaching students list 2021-12-11 23:49:39 +03:00
9eaca99e3b feat: return paginated users base on parameters 2021-12-09 23:51:19 +03:00
9238b4bd4a Merge tag 'v1.0.1' into develop
Release 1.0.1
2021-12-08 18:28:50 +03:00
4256ce618d Merge branch 'release/1.0.1' 2021-12-08 18:26:28 +03:00
6c2205648d readme 2021-12-08 18:24:15 +03:00
453e394edd refactor: comments in lesson repo 2021-12-08 18:08:13 +03:00
1ddcca2b6a fix: updating contacts & creating lesson materials 2021-12-08 18:04:56 +03:00
97171c1352 fix: saving materials for lesson 2021-12-08 01:26:48 +03:00
d403b7b87a feat: send materials count to frontend 2021-12-07 19:11:15 +03:00
56493f97b4 fixed: show color data when deleting a lesson 2021-11-29 23:03:58 +03:00
426a3283f6 chore: commented unnecessary module route 2021-11-25 21:28:17 +03:00
6acae4fd8f Merge tag 'v1.0.0' into develop
Release v1.0.0
2021-11-25 21:20:17 +03:00
112 changed files with 16827 additions and 2779 deletions

View File

@ -24,7 +24,7 @@
"Database": "database",
"Contracts": "contracts"
},
"preloads": ["./routes/index", "./start/kernel", "./start/event", "./start/bouncer"],
"preloads": ["./start/routes", "./start/kernel", "./start/event", "./start/bouncer"],
"providers": [
"./providers/AppProvider",
"@adonisjs/core",
@ -34,7 +34,8 @@
"@adonisjs/mail",
"@adonisjs/view",
"@adonisjs/attachment-lite",
"@adonisjs/bouncer"
"@adonisjs/bouncer",
"@adonisjs/drive-s3"
],
"aceProviders": ["@adonisjs/repl"],
"metaFiles": [

View File

@ -1,6 +1,20 @@
build
# Adonis default .gitignore ignores
node_modules
.git
*.log
build
coverage
.vscode
.DS_STORE
.env
Dockerfile
tmp
uploads
# Additional .gitignore ignores (any custom file you wish)
.idea
# Additional good to have ignores for dockerignore
Dockerfile*
docker-compose*
.dockerignore
*.md
.git
.gitignore

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,8 +1,71 @@
# Changelog
## Unreleased
## [1.4.2] - 2022-12-06
- API deployment with Docker and Nginx web server.
## Added
- Uploading video from url source (YouTube, Vimeo, .mp4, etc..)
- Added student and teacher to seeders
## Fix
- Removing lesson files from drive when deleting course, category or user.
- Fixed lesson display ordering when seeding database
- Refactored bouncer actions
## [1.3.2] - 2022-06-06
## Added
- Updated npm dependencies
- [Added](https://github.com/sergeyyarkov/educt-server/commit/294a4f584dd39d295372802305004365fb01fdb2) `MAILER` env variable
## Fix
- Fixed schema validator on saving lesson order
- Fixed lesson [duration updating](https://github.com/sergeyyarkov/educt-server/commit/ffdebc4f927abe3b63c62c84268f40f9e6663895#diff-86d18314585862595621b4e90f675b79ac74e7a0a47b2797556902eeff75e14a)
- Updated cors config
- Deleted `api` from path url
## [1.3.1] - 2022-04-10
## Added
- Docker files for building container
## [1.2.1] - 2022-03-06
## Added
- Saving latest notifications about new messages
- Saving recent conversation messages based on constant (now 100)
- Simple chat implementation between users
- Added `about` and `last_login` columns for user
- Updating info api route of authorized user
### Fix
- Refactored routes import
- Fixed updating user info
- Cleaning up session store before starting websocket server
## [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
- Creating materials for lesson
- Contacts updating
## [1.0.0] - 2021-11-25

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
ARG NODE_IMAGE=node:14.17.3-alpine3.11
FROM ${NODE_IMAGE} AS base
RUN apk --no-cache add dumb-init
RUN mkdir -p /home/node/app && chown node:node /home/node/app
WORKDIR /home/node/app
USER node
RUN mkdir uploads
FROM base as builder
COPY --chown=node:node package*.json ./
RUN npm ci
COPY --chown=node:node . .
RUN node ace build --production
FROM base as production
ENV NODE_ENV=production
ENV PORT=3333
ENV HOST=0.0.0.0
WORKDIR /home/node/app
COPY --chown=node:node --from=builder /home/node/app/build .
RUN npm ci --production
EXPOSE 3333
CMD ["dumb-init", "node", "server.js"]

View File

@ -1,3 +1,8 @@
# About API
# educt-server
...
_This service is part of [Educt](https://github.com/sergeyyarkov/educt) which contains API application._
<!-- ## Requirements
- NodeJS >= 14.17.3
- Docker -->

View File

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

View File

@ -7,9 +7,10 @@ import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
import CategoryService from 'App/Services/CategoryService';
/**
* Validatoes
* Validators
*/
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
import BaseController from '../../BaseController';
@ -78,6 +79,21 @@ export default class CategoriesController extends BaseController {
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);

View File

@ -0,0 +1,38 @@
import { inject, Ioc } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import { ConversationType } from 'App/Store/MessageStore';
import IResponse from 'App/Datatypes/Interfaces/IResponse';
/**
* Enums
*/
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
/**
* Services
*/
import WsService from 'App/Services/WsService';
import BaseController from '../../BaseController';
@inject()
export default class ChatController extends BaseController {
/**
* Get list of conversations (users)
* GET /chat/conversations
*/
public async getConversations(ctx: HttpContextContract) {
const user = await ctx.auth.authenticate();
const data = await WsService.messageStore.getConversations(user.id);
const result: IResponse<ConversationType[]> = {
success: true,
status: HttpStatusEnum.OK,
message: 'Fetched conversations.',
data,
};
return this.sendResponse(ctx, result.data, result.message, result.status);
}
}
new Ioc().make(ChatController);

View File

@ -10,6 +10,8 @@ import CourseService from 'App/Services/CourseService';
* Validators
*/
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 DelCourseFromUserValidator from 'App/Validators/Course/DelCourseFromUserValidator';
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
*/
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
*/
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) {
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);
}
/**
* 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
* 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);
}
/**
* 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
* GET /courses/:id/students/count

View File

@ -1,7 +1,6 @@
import Application from '@ioc:Adonis/Core/Application';
import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import { schema } from '@ioc:Adonis/Core/Validator';
import { schema, rules } from '@ioc:Adonis/Core/Validator';
/**
* Services
@ -49,7 +48,7 @@ export default class LessonsController extends BaseController {
* GET /lessons/:id
*/
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) {
throw new Exception(result.message, result.status, result.error.code);
@ -89,7 +88,7 @@ export default class LessonsController extends BaseController {
/**
* Update lesson by id.
* PUT /lessons/:id
* PATCH /lessons/:id
*/
public async update(ctx: HttpContextContract) {
const payload = await ctx.request.validate(UpdateLessonValidator);
@ -118,22 +117,18 @@ export default class LessonsController extends BaseController {
/**
* Get lesson materials by file name
* GET /lessons/materials/:file
* GET /lessons/materials/:fileName
*/
public async getMaterial(ctx: HttpContextContract) {
const { file } = ctx.request.params();
const { data, message, status, success, error } = await this.lessonService.fetchMaterialFile(ctx, file);
const { fileName } = ctx.request.params();
const { data, message, status, success, error } = await this.lessonService.fetchMaterialFile(ctx, fileName);
if (!success && error) {
throw new Exception(message, status, error.code);
}
/**
* Send file to user
*/
if (data instanceof LessonMaterial) {
const path = Application.makePath(data.url);
return this.sendFile(ctx, path, data.clientName);
return this.sendFileFromDrive(`materials/${data.name}`, ctx);
}
return this.sendResponse(ctx, data, message, status);
@ -146,7 +141,7 @@ export default class LessonsController extends BaseController {
// eslint-disable-next-line class-methods-use-this
public async saveOrder(ctx: HttpContextContract) {
const saveOrderSchema = schema.create({
ids: schema.array().members(schema.string()),
ids: schema.array([rules.minLength(1)]).members(schema.string()),
});
const payload = await ctx.request.validate({ schema: saveOrderSchema });
const { data, message, status, success, error } = await this.lessonService.updateOrder(payload.ids);
@ -157,6 +152,20 @@ export default class LessonsController extends BaseController {
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);

View File

@ -12,6 +12,7 @@ import MeService from 'App/Services/MeService';
*/
import ChangePasswordValidator from 'App/Validators/Password/ChangePasswordValidator';
import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator';
import UpdateUserInfoValidator from 'App/Validators/User/UpdateUserInfoValidator';
import BaseController from '../../BaseController';
@ -96,7 +97,7 @@ export default class MeController extends BaseController {
/**
* Update contacts of authenticated user
* PUT /me/contacts
* PATCH /me/contacts
*/
public async updateContacts(ctx: HttpContextContract) {
const payload = await ctx.request.validate(UpdateContactsValidator);
@ -108,6 +109,35 @@ export default class MeController extends BaseController {
return this.sendResponse(ctx, result.data, result.message, result.status);
}
/**
* Update personal info of user
* PATCH /me/info
*/
public async updateInfo(ctx: HttpContextContract) {
const payload = await ctx.request.validate(UpdateUserInfoValidator);
const result = await this.meService.updateUserInfo(payload, ctx.auth);
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);
}
/**
* Get messages of conversation by chat id
* GET /me/messages
*/
public async chatHistory(ctx: HttpContextContract) {
const result = await this.meService.fetchChatHistory(ctx.params.chat_id, ctx.auth);
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(MeController);

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

@ -93,7 +93,7 @@ export default class UsersController extends BaseController {
*/
public async delete(ctx: HttpContextContract) {
const result = await this.userService.deleteUser(ctx.params.id);
const result = await this.userService.deleteUser(ctx.params.id, ctx);
if (!result.success && result.error) {
throw new Exception(result.message, result.status, result.error.code);

View File

@ -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 {
@ -14,7 +16,27 @@ export default class BaseController {
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']>) {
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;
}
public handle() {
this.onConnected(this.socket);
this.socket.on('disconnect', reason => this.onDisconnected(reason));
}
public sendOnline(online: [string, { userId: string; userName: string }][]) {
this.io.sockets.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.getOnlineSessions())
.then(online => this.sendOnline(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.getOnlineSessions())
.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 {
Beige = '#F5F5DC',
IndianRed = '#CD5C5C',
FireBrick = '#B22222',
Pink = '#FFC0CB',
Tomato = '#FF6347',
Orange = '#FFA500',
Gold = '#FFD700',
Moccasin = '#FFE4B5',
Khaki = '#F0E68C',
Purple = '#800080',
Indigo = '#4B0082',
Teal = '#008080',
Cyan = '#0BC5EA',
Crimson = '#DC143C',
Chocolate = '#D2691E',
Salmon = '#FA8072',
Violet = '#EE82EE',
Blue = '#0000FF',
SteelBlue = '#4682B4',
RosyBrown = '#BC8F8F',
DarkSlateGray = '#2F4F4F',
DimGray = '#696969',
MediumVioletRed = '#C71585',
MediumOrchid = '#BA55D3',
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,28 @@
import Course from 'App/Models/Course';
export type FileEntry = {
path: string;
name: string;
};
export default class CourseHelper {
public static getVideoFileNames(course: Course): Array<FileEntry | null> {
const data = course.lessons.map(l => {
if (!l.video) return null;
return { path: 'videos/', name: l.video.name };
});
return data;
}
public static getMaterialFileNames(course: Course): FileEntry[] {
const data = course.lessons.flatMap(l => l.materials.map(m => ({ path: 'materials/', name: m.name })));
return data;
}
public static getImageFileName(course: Course): FileEntry | null {
if (!course.image) return null;
const data = { path: 'images/courses/', name: course.image.name };
return data;
}
}

View File

@ -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
*/
let guardLastAttempted: string | undefined;
const authPromises: Promise<boolean>[] = guards.map(guard => auth.use(guard).check());
const authResults: boolean[] = await Promise.all(authPromises);
const authResults: boolean[] = await Promise.all(guards.map(guard => auth.use(guard).check()));
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)[]) {
/**
* Verify token from cookie and set the authorization header
* Set the authorization header for token
*/
const token = ctx.request.cookie('token');
if (token) {

View File

@ -1,5 +1,6 @@
import { Exception } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import User from 'App/Models/User';
export default class Role {
error: {
@ -23,7 +24,7 @@ export default class Role {
* @param {string[]} roles - Array of roles for route
* @param {string[]} userRole - Array of roles of current user
*/
protected checkRoles(roles: string[], userRoles: string[]): void {
protected check(roles: string[], userRoles: string[]): void {
if (!roles.some(role => userRoles.includes(role))) {
/**
* Failed to verify user rights
@ -33,8 +34,20 @@ export default class Role {
}
public async handle({ auth }: HttpContextContract, next: () => Promise<void>, roles: string[]) {
const { userRoles } = auth.use('api').token?.meta;
this.checkRoles(roles, userRoles);
const { userId } = auth.use('api').token?.meta;
const user = await User.query().where('id', userId).preload('roles').first();
if (!user) {
/**
* User not found
*/
throw new Exception('Cannot identify current user.', this.error.status, this.error.code);
}
this.check(
roles,
user.roles.map(role => role.slug)
);
await next();
}
}

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

View File

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

View File

@ -1,11 +1,24 @@
/* eslint-disable import/no-cycle */
/* eslint-disable no-param-reassign */
import { DateTime } from 'luxon';
import { nanoid } from 'nanoid';
import { BaseModel, beforeCreate, BelongsTo, belongsTo, column, HasOne, hasOne } from '@ioc:Adonis/Lucid/Orm';
import {
BaseModel,
beforeCreate,
BelongsTo,
belongsTo,
column,
hasMany,
HasMany,
HasOne,
hasOne,
} from '@ioc:Adonis/Lucid/Orm';
import ColorHelper from 'App/Helpers/ColorHelper';
import Course from './Course';
// eslint-disable-next-line import/no-cycle
import LessonMaterial from './LessonMaterial';
import LessonContent from './LessonContent';
import LessonProgress from './LessonProgress';
import LessonVideo from './LessonVideo';
import Color from './Color';
export default class Lesson extends BaseModel {
@ -24,6 +37,9 @@ export default class Lesson extends BaseModel {
@column()
public description: string;
@column()
public linked_video_url: string | null;
@column()
public duration: string;
@ -45,6 +61,19 @@ export default class Lesson extends BaseModel {
})
public content: HasOne<typeof LessonContent>;
@hasOne(() => LessonVideo, {
foreignKey: 'lesson_id',
})
public video: HasOne<typeof LessonVideo>;
@hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' })
public materials: HasMany<typeof LessonMaterial>;
@hasOne(() => LessonProgress, {
foreignKey: 'lesson_id',
})
public progress: HasOne<typeof LessonProgress>;
@column.dateTime({ autoCreate: true })
public createdAt: DateTime;
@ -68,4 +97,13 @@ export default class Lesson extends BaseModel {
course.color_id = color.id;
}
}
/**
* Serialize the `$extras` object
*/
public serializeExtras() {
return {
materials_count: this.$extras.materials_count,
};
}
}

View File

@ -1,8 +1,7 @@
/* eslint-disable import/no-cycle */
import { BaseModel, BelongsTo, belongsTo, column, HasMany, hasMany } from '@ioc:Adonis/Lucid/Orm';
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm';
import { DateTime } from 'luxon';
import Lesson from './Lesson';
import LessonMaterial from './LessonMaterial';
export default class LessonContent extends BaseModel {
@column({ isPrimary: true })
@ -12,10 +11,7 @@ export default class LessonContent extends BaseModel {
public lesson_id: string;
@column()
public video_url: string;
@hasMany(() => LessonMaterial, { foreignKey: 'lesson_content_id' })
public materials: HasMany<typeof LessonMaterial>;
public body: string;
@belongsTo(() => Lesson, { foreignKey: 'lesson_id' })
public lesson: BelongsTo<typeof Lesson>;

View File

@ -1,17 +1,17 @@
/* eslint-disable import/no-cycle */
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm';
import { DateTime } from 'luxon';
// eslint-disable-next-line import/no-cycle
import LessonContent from './LessonContent';
import Lesson from './Lesson';
export default class LessonMaterial extends BaseModel {
@column({ isPrimary: true })
public id: number;
@column()
public url: string;
public name: string;
@column()
public name: string;
public size: number;
@column()
public clientName: string;
@ -20,12 +20,12 @@ export default class LessonMaterial extends BaseModel {
public ext: string;
@column()
public lesson_content_id: number;
public lesson_id: string;
@belongsTo(() => LessonContent, {
foreignKey: 'lesson_content_id',
@belongsTo(() => Lesson, {
foreignKey: 'lesson_id',
})
public content: BelongsTo<typeof LessonContent>;
public lesson: BelongsTo<typeof Lesson>;
@column.dateTime({ autoCreate: true })
public createdAt: DateTime;

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,
column,
computed,
HasMany,
hasMany,
HasOne,
hasOne,
ManyToMany,
@ -17,6 +19,7 @@ import { nanoid } from 'nanoid';
import Role from 'App/Models/Role';
import Contact from 'App/Models/Contact';
import Course from 'App/Models/Course';
import LessonProgress from './LessonProgress';
export default class User extends BaseModel {
@column({ isPrimary: true })
@ -62,10 +65,21 @@ export default class User extends BaseModel {
})
public likes: ManyToMany<typeof Course>;
@column.dateTime({ autoCreate: true, serializeAs: null })
@hasMany(() => LessonProgress, {
foreignKey: 'user_id',
})
public lessons_progress: HasMany<typeof LessonProgress>;
@column()
public about: string | null;
@column.dateTime()
public last_login: DateTime | null;
@column.dateTime({ autoCreate: true })
public createdAt: DateTime;
@column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: null })
@column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime;
/**

View File

@ -0,0 +1,35 @@
import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer';
/**
* Helpers
*/
import RoleHelper from 'App/Helpers/RoleHelper';
/**
* Models
*/
import Lesson from 'App/Models/Lesson';
import User from 'App/Models/User';
/**
* Datatypes
*/
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
export default class LessonPolicy extends BasePolicy {
public async view(user: User, lesson: Lesson) {
/**
* Load required data to check permissions
*/
await user.load(loader => loader.load('roles').load('courses'));
await lesson.load('course');
const isAdminOrTeacher = RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]);
if (isAdminOrTeacher) {
return true;
}
return !!user.courses.find(course => course.id === lesson.course.id);
}
}

View File

@ -0,0 +1,36 @@
import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer';
/**
* Models
*/
import User from 'App/Models/User';
/**
* Helpers
*/
import RoleHelper from 'App/Helpers/RoleHelper';
/**
* Datatypes
*/
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
export default class RolePolicy extends BasePolicy {
public async manage(user: User, roles: Array<RoleEnum>) {
await user.load('roles');
const isAdmin = RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN]);
const isTeacher = RoleHelper.userContainRoles(user.roles, [RoleEnum.TEACHER]);
if (isAdmin) {
return true;
}
/**
* User with role `TEACHER` can manage only user with `STUDENT` role
*/
if (!(roles.includes(RoleEnum.TEACHER) || roles.includes(RoleEnum.ADMIN)) && isTeacher) {
return true;
}
}
}

View File

@ -7,6 +7,7 @@ import Category from 'App/Models/Category';
* Validators
*/
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
export default class CategoryRepository {
private Category: typeof Category;
@ -67,4 +68,22 @@ export default class CategoryRepository {
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

@ -63,12 +63,7 @@ export default class ContactRepository {
const contacts = await this.Contact.query().where('user_id', user.id).first();
if (contacts) {
contacts.phone_number = data.phone_number ?? null;
contacts.telegram_id = data.telegram_id?.substring(1) ?? null;
contacts.twitter_id = data.twitter_id?.substring(1) ?? null;
contacts.vk_id = data.vk_id ?? null;
await contacts.save();
await contacts.merge(data).save();
await user.load('contacts');
return contacts;

View File

@ -32,6 +32,32 @@ export default class CourseRepository {
this.Course = Course;
}
/**
* Get list of courses by teacher id
*
* @param id Teacher id
* @returns Array of courses
*/
public async getByTeacherId(id: string | number): Promise<Course[]> {
const data = await this.Course.query()
.preload('lessons', q => q.preload('video').preload('materials'))
.where('teacher_id', id);
return data;
}
/**
* Get list of courses by category id
*
* @param id Category id
* @returns Array of courses
*/
public async getByCategoryId(id: string | number): Promise<Course[]> {
const data = await this.Course.query()
.preload('lessons', q => q.preload('video').preload('materials'))
.where('category_id', id);
return data;
}
/**
* Set status on course
*
@ -49,7 +75,7 @@ export default class CourseRepository {
* @returns List of courses
*/
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();
@ -61,12 +87,17 @@ export default class CourseRepository {
query.andWhere('status', status);
}
if (limit) {
query.limit(limit);
}
const courses = await query
.preload('color')
.preload('category')
.withCount('students')
.withCount('likes')
.withCount('lessons');
.withCount('lessons')
.orderBy('created_at', 'desc');
return courses;
}
@ -81,9 +112,12 @@ export default class CourseRepository {
const course = await this.Course.query()
.preload('teacher')
.preload('color')
.preload('category')
.preload('lessons', q => q.preload('color').orderBy('display_order', 'asc'))
.preload('students')
.preload('category', q => q.preload('color'))
.preload('lessons', q => q.preload('color').orderBy('display_order', 'asc').withCount('materials'))
.preload('students', q => q.preload('roles').preload('contacts'))
.withCount('likes')
.withCount('lessons')
.withCount('students')
.where('id', id)
.first();
@ -153,8 +187,17 @@ export default class CourseRepository {
* @param id Course id
* @returns List of lessons or null
*/
public async getLessons(id: string | number): Promise<Lesson[] | null> {
const course = await this.Course.query().select('id').preload('lessons').where('id', id).first();
public async getLessons(id: string | number, userId: string): Promise<Lesson[] | null> {
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;
}
@ -194,6 +237,7 @@ export default class CourseRepository {
course.title = data.title;
course.description = data.description;
course.education_description = data.education_description;
course.status = data.status;
course.teacher_id = data.teacher_id;
course.category_id = data.category_id;
@ -219,6 +263,7 @@ export default class CourseRepository {
public async delete(id: string | number): Promise<Course | null> {
const course = await this.Course.query()
.preload('category')
.preload('lessons', q => q.preload('video').preload('materials'))
.withCount('students')
.withCount('likes')
.withCount('lessons')
@ -255,6 +300,7 @@ export default class CourseRepository {
course.merge({
title: data.title,
description: data.description,
education_description: data.education_description,
teacher_id: data.teacher_id,
category_id: data.category_id,
});
@ -269,10 +315,11 @@ export default class CourseRepository {
/**
* Load other updated data
*/
course.load('category');
course.load('teacher');
await course.load('category');
await course.load('teacher');
await course.save();
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,10 +1,14 @@
import Drive from '@ioc:Adonis/Core/Drive';
import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser';
import { cuid } from '@ioc:Adonis/Core/Helpers';
/**
* Models
*/
import Drive from '@ioc:Adonis/Core/Drive';
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
@ -17,9 +21,15 @@ 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;
}
/**
@ -39,7 +49,7 @@ export default class LessonRepository {
* @returns Lesson or 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;
}
@ -55,16 +65,32 @@ export default class LessonRepository {
}
/**
* Get lesson material
* Get lesson material by name
*
* @param fileName Name of file
* @returns Lesson material file or null
* @returns Lesson material or null
*/
public async getMaterialFileByName(fileName: string): Promise<LessonMaterial | null> {
const material = await this.LessonMaterial.query().where('name', fileName).first();
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
*
@ -79,42 +105,33 @@ 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,
linked_video_url: !data.video ? data.linked_video_url : null,
});
/**
* Attach lesson to course
* Create content
*/
await lesson.related('course').associate(course);
await lesson.related('content').create({ body: '' });
/**
* Create content and materials fields in database
* Move video file to disk and save to database
*/
const content = await lesson.related('content').create({ video_url: data.video_url });
if (data.video) {
await this.createVideo(lesson, data.video);
}
/**
* Create materials
*/
if (data.materials) {
await Promise.all(
data.materials.map(async file => {
/**
* Move each file to disk and then save in database
*/
await file.moveToDisk('materials');
if (file.state === 'moved') {
const url = await Drive.getUrl(`materials/${file.fileName}`);
await content.related('materials').create({
name: file.fileName,
clientName: file.clientName,
ext: file.extname,
url,
});
}
})
);
await this.createMaterials(lesson, data.materials);
}
/**
* Load data
*/
await lesson.load('content', q => q.preload('materials'));
await lesson.load(loader => loader.load('content').load('materials').load('video').load('progress'));
return lesson;
}
@ -126,19 +143,58 @@ export default class LessonRepository {
* @returns Updated lesson
*/
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) {
await lesson
.merge({
course_id: data.course_id,
title: data.title,
description: data.description,
})
.save();
/**
* Update fields
*/
lesson.merge({
title: data.title,
description: data.description,
course_id: data.course_id,
...(data.duration && { duration: data.duration.toFormat('HH:mm:ss') }),
});
/**
* Update linked video url
*/
if (data.linked_video_url && !data.video) {
if (lesson.video) {
await this.Drive.delete(`videos/${lesson.video.name}`);
}
await lesson.related('video').query().delete();
lesson.merge({ linked_video_url: data.linked_video_url });
}
/**
* Update file video
*/
if (data.video) {
if (lesson.video) {
await this.Drive.delete(`videos/${lesson.video.name}`);
}
await lesson.related('video').query().delete();
await this.createVideo(lesson, data.video);
lesson.merge({ linked_video_url: null });
}
/**
* 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 null;
}
@ -149,13 +205,89 @@ export default class LessonRepository {
* @returns Deleted lesson or null
*/
public async delete(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('video').preload('color').first();
if (lesson) {
/**
* Delete files from disk
*/
if (lesson.video) {
await this.Drive.delete(`videos/${lesson.video.name}`);
}
await this.deleteMaterials(lesson);
/**
* Delete lesson from database
*/
await lesson.delete();
return lesson;
}
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

@ -1,4 +1,5 @@
import Hash from '@ioc:Adonis/Core/Hash';
import { DateTime } from 'luxon';
/**
* Models
@ -11,6 +12,7 @@ import Role from 'App/Models/Role';
*/
import CreateUserValidator from 'App/Validators/User/CreateUserValidator';
import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator';
import UpdateUserInfoValidator from 'App/Validators/User/UpdateUserInfoValidator';
export default class UserRepository {
private User: typeof User;
@ -30,6 +32,11 @@ export default class UserRepository {
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
*
@ -64,7 +71,7 @@ export default class UserRepository {
*/
public async getAll(params?: any) {
// 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();
@ -97,8 +104,21 @@ export default class UserRepository {
query.whereHas('roles', q => q.where('slug', role));
}
const data = await query.preload('contacts').preload('roles').orderBy('created_at', 'desc').paginate(page, limit);
return data.toJSON();
query.preload('contacts').preload('roles').orderBy('created_at', 'desc');
/**
* 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;
}
/**
@ -141,6 +161,32 @@ export default class UserRepository {
return null;
}
/**
* Updates the time of last login column in database for user
*
* @param id User id
*/
public async updateLastLogin(id: number | string) {
await this.User.query().where('id', id).update({ last_login: DateTime.now() }).first();
}
/**
* Update personal info of user
*
* @param data New fields
*/
public async updateInfo(id: string | number, data: UpdateUserInfoValidator['schema']['props']) {
const user = await this.User.query().preload('contacts').preload('roles').where('id', id).first();
const { about } = data;
if (user) {
await user.merge({ about }).save();
return { about: user.about };
}
return null;
}
/**
* Rewrite all user roles
*

View File

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

View File

@ -10,18 +10,35 @@ import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
* Repositories
*/
import CategoryRepository from 'App/Repositories/CategoryRepository';
import CourseRepository from 'App/Repositories/CourseRepository';
/**
* Validators
*/
import CreateCategoryValidator from 'App/Validators/Category/CreateCategoryValidator';
import UpdateCategoryValidator from 'App/Validators/Category/UpdateCategoryValidator';
/**
* Services
*/
import CourseService from 'App/Services/CourseService';
@inject()
export default class CategoryService {
private categoryRepository: CategoryRepository;
constructor(categoryRepository: CategoryRepository) {
private courseRepository: CourseRepository;
private courseService: CourseService;
constructor(
categoryRepository: CategoryRepository,
courseRepository: CourseRepository,
courseService: CourseService
) {
this.categoryRepository = categoryRepository;
this.courseRepository = courseRepository;
this.courseService = courseService;
}
/**
@ -93,7 +110,7 @@ export default class CategoryService {
* @returns Deleted category
*/
public async deleteCategory(id: string | number): Promise<IResponse> {
const data = await this.categoryRepository.delete(id);
const data = await this.categoryRepository.getById(id);
if (!data) {
return {
@ -107,11 +124,47 @@ export default class CategoryService {
};
}
const courses = await this.courseRepository.getByCategoryId(id);
await this.courseService.deleteAllFiles(courses);
await this.categoryRepository.delete(id);
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Category deleted.',
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 deleted.',
data,
message: 'Category updated.',
data: category,
};
}
}

View File

@ -1,5 +1,6 @@
import { inject, Ioc } from '@adonisjs/core/build/standalone';
import { AuthContract } from '@ioc:Adonis/Addons/Auth';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
/**
* Datatypes
@ -23,6 +24,9 @@ import CategoryRepository from 'App/Repositories/CategoryRepository';
import CreateCourseValidator from 'App/Validators/Course/CreateCourseValidator';
import UpdateCourseValidator from 'App/Validators/Course/UpdateCourseValidator';
import FetchCoursesValidator from 'App/Validators/Course/FetchCoursesValidator';
import Course from 'App/Models/Course';
import Drive from '@ioc:Adonis/Core/Drive';
import CourseHelper, { FileEntry } from 'App/Helpers/CourseHelper';
@inject()
export default class CourseService {
@ -64,7 +68,7 @@ export default class CourseService {
* @param id Course id
* @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);
if (!data) {
@ -151,8 +155,9 @@ export default class CourseService {
* @param id Course id
* @returns Response
*/
public async fetchCourseLessons(id: string | number): Promise<IResponse> {
const data = await this.courseRepository.getLessons(id);
public async fetchCourseLessons(id: string | number, ctx: HttpContextContract): Promise<IResponse> {
const user = await ctx.auth.use('api').authenticate();
const data = await this.courseRepository.getLessons(id, user.id);
if (!data) {
return {
@ -230,9 +235,9 @@ export default class CourseService {
/**
* Find course teacher
*/
const teacher = await this.userRepository.getById(data.teacher_id);
const user = await this.userRepository.getById(data.teacher_id);
if (!teacher) {
if (!user) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
@ -244,13 +249,13 @@ export default class CourseService {
};
}
const isTeacher = RoleHelper.userHasRoles(teacher.roles, [RoleEnum.TEACHER]);
const isHasPermissions = RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]);
if (!isTeacher) {
if (!isHasPermissions) {
return {
success: false,
status: HttpStatusEnum.BAD_REQUEST,
message: `User with id "${teacher.id}" not a teacher.`,
message: `User with id "${user.id}" does not have sufficient permissions.`,
data: {},
error: {
code: 'E_BAD_REQUEST',
@ -307,6 +312,8 @@ export default class CourseService {
};
}
await this.deleteAllFiles([course]);
return {
success: true,
status: HttpStatusEnum.OK,
@ -323,7 +330,7 @@ export default class CourseService {
* @returns Response
*/
public async updateCourse(id: string | number, data: UpdateCourseValidator['schema']['props']): Promise<IResponse> {
const course = await this.courseRepository.update(id, data);
const course = await this.courseRepository.getById(id);
if (!course) {
return {
@ -337,11 +344,45 @@ export default class CourseService {
};
}
if (data.teacher_id) {
const teacher = await this.userRepository.getById(data.teacher_id);
if (!teacher) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
message: 'Teacher not found.',
data: {},
error: {
code: 'E_NOT_FOUND',
},
};
}
await teacher.load('roles');
const isTeacherOrAdmin = RoleHelper.userContainRoles(teacher.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER]);
if (!isTeacherOrAdmin) {
return {
success: false,
status: HttpStatusEnum.BAD_REQUEST,
message: 'Author is not a teacher or admin.',
data: {},
error: {
code: 'E_BAD_REQUEST',
},
};
}
}
const updated = await this.courseRepository.update(id, data);
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Course updated.',
data: course,
data: updated || {},
};
}
@ -420,6 +461,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> {
const course = await this.courseRepository.getById(id);
@ -577,6 +705,29 @@ export default class CourseService {
data: { count },
};
}
/**
* This function will delete all associated course files on disk
*
* @param courses List of courses
*/
public async deleteAllFiles(courses: Course[]): Promise<void> {
/**
* Collect file names
*/
const images = courses.map(course => CourseHelper.getImageFileName(course)).filter(Boolean) as FileEntry[];
const videos = courses.flatMap(course => CourseHelper.getVideoFileNames(course)).filter(Boolean) as FileEntry[];
const materials = courses.flatMap(course => CourseHelper.getMaterialFileNames(course));
/**
* Remove files from disk
*/
const promises = videos
.concat(images, materials)
.map(file => Drive.delete(`${file.path}${file.name.substring(file.name.lastIndexOf('/') + 1)}`));
await Promise.all(promises);
}
}
new Ioc().make(CourseService);

View File

@ -8,11 +8,13 @@ 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
*/
import CourseRepository from 'App/Repositories/CourseRepository';
import LessonProgressRepository from 'App/Repositories/LessonProgressRepository';
import LessonRepository from 'App/Repositories/LessonRepository';
/**
@ -27,9 +29,16 @@ export default class LessonService {
private lessonRepository: LessonRepository;
constructor(courseRepository: CourseRepository, lessonRepository: LessonRepository) {
private lessonProgressRepository: LessonProgressRepository;
constructor(
courseRepository: CourseRepository,
lessonRepository: LessonRepository,
lessonProgressRepository: LessonProgressRepository
) {
this.courseRepository = courseRepository;
this.lessonRepository = lessonRepository;
this.lessonProgressRepository = lessonProgressRepository;
}
/**
@ -54,7 +63,7 @@ export default class LessonService {
* @param id Lesson id
* @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);
if (!lesson) {
@ -69,6 +78,13 @@ export default class LessonService {
};
}
await ctx.bouncer.with('LessonPolicy').authorize('view', lesson);
/**
* Load lesson with materials
*/
await lesson.load(loader => loader.load('content').load('materials').load('video'));
return {
success: true,
status: HttpStatusEnum.OK,
@ -80,11 +96,12 @@ export default class LessonService {
/**
* Fetch lesson material by file name
*
* @param fileName Name of file
* @param ctx Http context
* @param name Filename
* @returns Response
*/
public async fetchMaterialFile(ctx: HttpContextContract, fileName: string): Promise<IResponse<LessonMaterial>> {
const material = await this.lessonRepository.getMaterialFileByName(fileName);
public async fetchMaterialFile(ctx: HttpContextContract, name: string): Promise<IResponse<LessonMaterial>> {
const material = await this.lessonRepository.getMaterialFileByName(name);
if (!material) {
return {
@ -101,22 +118,8 @@ export default class LessonService {
/**
* Allow user to view lesson material
*/
await material.load('content', q => q.preload('lesson'));
const {
content: { lesson },
} = material;
if (await ctx.bouncer.denies('viewLessonContent', lesson)) {
return {
success: false,
status: HttpStatusEnum.FORBIDDEN,
message: 'You are not able to view this file.',
data: {},
error: {
code: 'E_FORBIDDEN',
},
};
}
await material.load('lesson');
await ctx.bouncer.with('LessonPolicy').authorize('view', material.lesson);
return {
success: true,
@ -126,6 +129,72 @@ 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');
await ctx.bouncer.with('LessonPolicy').authorize('view', video.lesson);
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',
},
};
}
await ctx.bouncer.with('LessonPolicy').authorize('view', lesson);
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
*
@ -147,6 +216,9 @@ export default class LessonService {
};
}
/**
* Create new lesson
*/
const lesson = await this.lessonRepository.create(course, data);
return {
@ -238,25 +310,12 @@ export default class LessonService {
};
}
/**
* Allow user to view lesson content
*/
if (await ctx.bouncer.denies('viewLessonContent', lesson)) {
return {
success: false,
status: HttpStatusEnum.FORBIDDEN,
message: 'The user is not a student of this course.',
data: {},
error: {
code: 'E_FORBIDDEN',
},
};
}
await ctx.bouncer.with('LessonPolicy').authorize('view', lesson);
/**
* Load lesson content with materials
* Load lesson with materials
*/
await lesson.load('content', q => q.preload('materials'));
await lesson.load(loader => loader.load('content').load('materials'));
return {
success: true,

View File

@ -5,14 +5,36 @@ import Redis from '@ioc:Adonis/Addons/Redis';
import Mail from '@ioc:Adonis/Addons/Mail';
import Hash from '@ioc:Adonis/Core/Hash';
import Logger from '@ioc:Adonis/Core/Logger';
/**
* Datatypes
*/
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
import IResponse from 'App/Datatypes/Interfaces/IResponse';
import CodeErrorEnum from 'App/Datatypes/Enums/CodeErrorEnum';
/**
* Repositories
*/
import ContactRepository from 'App/Repositories/ContactRepository';
import UserRepository from 'App/Repositories/UserRepository';
/**
* Validator
*/
import UpdateUserInfoValidator from 'App/Validators/User/UpdateUserInfoValidator';
import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator';
import CodeErrorEnum from 'App/Datatypes/Enums/CodeErrorEnum';
/**
* Helpers
*/
import RoleHelper from 'App/Helpers/RoleHelper';
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
/**
* Services
*/
import Ws from './WsService';
@inject()
export default class MeService {
@ -37,7 +59,10 @@ export default class MeService {
const user = await auth.use(this.authGuard).authenticate();
await user.load(loader => {
loader.load('roles').load('contacts');
loader
.load('roles')
.load('contacts')
.load('likes', q => q.select('id'));
});
/**
@ -52,11 +77,16 @@ export default class MeService {
});
}
const notifications = await Ws.notificationStore.getNotifications(user.id);
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Fetched authorized user data.',
data: user,
data: {
...user.toJSON(),
notifications,
},
};
}
@ -244,9 +274,9 @@ export default class MeService {
/**
* Update user conctact
*
* @param data Data to update
* @param data Fields to update
* @param auth AuthContract
* @returns Updated user contacts
* @returns IResponse
*/
public async updateUserContacts(
data: UpdateContactsValidator['schema']['props'],
@ -276,6 +306,93 @@ export default class MeService {
data: user.contacts,
};
}
/**
* Update info about user
*
* @param data Fields to update
* @param auth AuthContract
* @returns IResponse
*/
public async updateUserInfo(
data: UpdateUserInfoValidator['schema']['props'],
auth: AuthContract
): Promise<IResponse> {
const user = await auth.use(this.authGuard).authenticate();
const { about, ...newContacts } = data;
if (Object.keys(data).length === 0) {
return {
success: false,
status: HttpStatusEnum.BAD_REQUEST,
message: 'You must specify at least one field.',
data: {},
error: {
code: 'E_BAD_REQUEST',
},
};
}
/**
* Personal info updating
*/
if (about !== undefined) {
await this.userRepository.updateInfo(user.id, data);
}
/**
* Contacts updating
*/
if (data.phone_number !== undefined || data.twitter_id !== undefined || data.telegram_id !== undefined) {
const contacts = await this.contactRepository.update(user, newContacts);
if (!contacts) {
await this.contactRepository.create(user, newContacts);
}
}
return {
success: true,
status: HttpStatusEnum.OK,
message: 'User contacts updated.',
data: { ...data },
};
}
/**
* Fetch history of chat
*
* @param chatId Id of user
* @param auth AuthContract
* @returns IResponse
*/
public async fetchChatHistory(chatId: string, auth: AuthContract): Promise<IResponse> {
const firstUser = await auth.use(this.authGuard).authenticate();
const secondUser = await this.userRepository.getById(chatId);
if (!secondUser) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
message: 'Chat not found.',
data: {},
error: {
code: 'E_NOT_FOUND',
},
};
}
const history = await Ws.messageStore.getHistory(firstUser.id, secondUser.id);
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Chat history fetched.',
data: {
history,
},
};
}
}
new Ioc().make(MeService);

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.getOnlineSessions();
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Fetched stat.',
data: {
...count.rows[0],
online: online.length,
},
};
}
}
new Ioc().make(StatService);

View File

@ -22,21 +22,48 @@ import Role from 'App/Models/Role';
import RoleRepository from 'App/Repositories/RoleRepository';
import UserRepository from 'App/Repositories/UserRepository';
/**
* Services
*/
import CourseService from './CourseService';
/**
* Validators
*/
import CreateUserValidator from 'App/Validators/User/CreateUserValidator';
import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator';
/**
* Datatypes
*/
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
/**
* Helpers
*/
import RoleHelper from 'App/Helpers/RoleHelper';
import CourseRepository from 'App/Repositories/CourseRepository';
@inject()
export default class UserService {
private userRepository: UserRepository;
private roleRepository: RoleRepository;
constructor(userRepository: UserRepository, roleRepository: RoleRepository) {
private courseRepository: CourseRepository;
private courseService: CourseService;
constructor(
userRepository: UserRepository,
roleRepository: RoleRepository,
courseRepository: CourseRepository,
courseService: CourseService
) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.courseRepository = courseRepository;
this.courseService = courseService;
}
/**
@ -76,16 +103,24 @@ export default class UserService {
*/
public async fetchUsers(params?: any): Promise<IResponse> {
const users = await this.userRepository.getAll(params);
return {
const result: IResponse = {
success: true,
status: HttpStatusEnum.OK,
message: 'Fetched all users.',
data: users.data,
meta: {
pagination: users.meta,
},
data: {},
};
if (users instanceof Array) {
result.data = users;
return result;
}
result.data = users.data;
result.meta = {
pagination: users.meta,
};
return result;
}
/**
@ -112,20 +147,7 @@ export default class UserService {
};
}
/**
* Check user permissions
*/
if (await ctx.bouncer.denies('manageUserRole', role)) {
return {
success: false,
status: HttpStatusEnum.FORBIDDEN,
message: 'You dont have permissions to permorm that action',
data: {},
error: {
code: 'E_FORBIDDEN',
},
};
}
await ctx.bouncer.with('RolePolicy').authorize('manage', [role.slug as RoleEnum]);
/**
* Create new user
@ -158,7 +180,7 @@ export default class UserService {
data: UpdateUserValidator['schema']['props'],
ctx: HttpContextContract
): Promise<IResponse> {
const user = await this.userRepository.update(id, data);
const user = await this.userRepository.getById(id);
if (!user) {
return {
@ -172,6 +194,8 @@ export default class UserService {
};
}
await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]);
/**
* Update user role
*/
@ -179,27 +203,18 @@ export default class UserService {
const role = await this.roleRepository.getBySlug(data.role);
if (role) {
if (await ctx.bouncer.denies('manageUserRole', role)) {
return {
success: false,
status: HttpStatusEnum.FORBIDDEN,
message: 'You dont have permissions to permorm that action',
data: {},
error: {
code: 'E_FORBIDDEN',
},
};
}
await ctx.bouncer.with('RolePolicy').authorize('manage', [data.role]);
await this.userRepository.updateRoles(user, [role]);
}
}
const updated = await this.userRepository.update(id, data);
return {
success: true,
status: HttpStatusEnum.OK,
message: 'User updated.',
data: user,
data: updated || {},
};
}
@ -209,8 +224,8 @@ export default class UserService {
* @param id User id
* @returns Response
*/
public async deleteUser(id: string | number): Promise<IResponse> {
const user = await this.userRepository.delete(id);
public async deleteUser(id: string | number, ctx: HttpContextContract): Promise<IResponse> {
const user = await this.userRepository.getById(id);
if (!user) {
return {
@ -224,6 +239,15 @@ export default class UserService {
};
}
await ctx.bouncer.with('RolePolicy').authorize('manage', user.roles.map(r => r.slug) as [RoleEnum]);
if (RoleHelper.userContainRoles(user.roles, [RoleEnum.TEACHER, RoleEnum.ADMIN])) {
const courses = await this.courseRepository.getByTeacherId(id);
await this.courseService.deleteAllFiles(courses);
}
await this.userRepository.delete(id);
return {
success: true,
status: HttpStatusEnum.OK,

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

@ -0,0 +1,172 @@
import { Server } from 'socket.io';
import Redis from '@ioc:Adonis/Addons/Redis';
import AdonisServer from '@ioc:Adonis/Core/Server';
import { DateTime } from 'luxon';
/**
* Controllers
*/
import OnlineController from 'App/Controllers/Ws/OnlineController';
/**
* Middlewares
*/
import AuthMiddleware from 'App/Middleware/Ws/Auth';
/**
* Stores
*/
import RedisSessionStore from 'App/Store/SessionStore';
import RedisMessageStore from 'App/Store/MessageStore';
import RedisNotificationStore from 'App/Store/NotificationStore';
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: [string, { userId: string; userName: string }][]) => void;
/**
* Chat
*/
'chat:message': (data: {
content: string;
time: string;
from: string;
to: string;
notificationId?: string | undefined;
}) => void;
}
export interface ClientToServerEvents {
/**
* User
*/
'user:logout': () => void;
'user:status': () => void;
/**
* Chat
*/
'chat:message': (data: { content: string; to: string }) => void;
/**
* Notification
*/
'notification:read': (data: { userId: string; ids: string[] }) => 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 messageStore: RedisMessageStore;
public notificationStore: RedisNotificationStore;
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,
},
});
/**
* Stores
*/
this.sessionStore = new RedisSessionStore(Redis.connection('session'));
this.messageStore = new RedisMessageStore(Redis.connection('message'));
this.notificationStore = new RedisNotificationStore(Redis.connection('notification'));
/**
* Clean up sessions before start
*/
this.sessionStore.destroyAllSessions().then(() => {
/**
* Middlewares
*/
this.setupMiddlewares();
/**
* On connection socket
*/
this.listen();
});
}
private listen() {
this.io.on('connection', async socket => {
const onlineController = new OnlineController(socket, this.io, this.sessionStore);
onlineController.handle();
const { sessionId, userId, userName } = socket.data;
const isExistSocketData = !!(sessionId && userId && userName);
if (isExistSocketData) {
socket.join(userId);
}
/**
* Send session to client
*/
socket.emit('user:session', { sessionId: socket.data.sessionId, userId: socket.data.userId });
socket.on('chat:message', async ({ content, to }) => {
if (isExistSocketData) {
const message = {
content,
from: userId,
to,
time: DateTime.now().toISO(),
};
await this.messageStore.add(userId, to, message);
if (userId !== to) {
const notification = await this.notificationStore.add(to, `You received a new private message`, 'MESSAGE');
this.io.to(to).emit('chat:message', { ...message, notificationId: notification.id });
} else {
this.io.to(to).emit('chat:message', { ...message });
}
}
});
socket.on('notification:read', data => this.notificationStore.del(data.userId, data.ids));
/**
* 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();

118
app/Store/MessageStore.ts Normal file
View File

@ -0,0 +1,118 @@
import { RedisConnectionContract } from '@ioc:Adonis/Addons/Redis';
import Logger from '@ioc:Adonis/Core/Logger';
import User from 'App/Models/User';
import { DateTime } from 'luxon';
import { nanoid } from 'nanoid';
type MessageDataType = {
from: string;
to: string;
content: string;
time: string;
};
export type ConversationType = {
userId: string;
fullname: string;
lastMessage: MessageDataType;
};
const MESSAGE_TTL = 185 * 24 * 60 * 60; // 185 days
const MAX_MESSAGES_HISTORY = 100;
class RedisMessageStore {
public redisClient: RedisConnectionContract;
constructor(redisClient: RedisConnectionContract) {
this.redisClient = redisClient;
}
public async add(firstUserId: string, secondUserId: string, data: MessageDataType): Promise<'OK' | 'ERROR'> {
try {
const msgId = nanoid();
const msgKey = `${firstUserId}:${msgId}`;
const key = `message:${msgKey}`;
await this.redisClient
.multi()
.hmset(key, {
from: data.from,
to: data.to,
content: data.content,
time: DateTime.now().toISO(),
})
.expire(key, MESSAGE_TTL)
.exec();
const convKey = this.createConversationKey(firstUserId, secondUserId);
const msgKeys = await this.redisClient.lrange(convKey, 0, -1);
await this.redisClient.lpush(convKey, msgKey);
if (msgKeys.length >= MAX_MESSAGES_HISTORY) {
const deletedMsgKey = await this.redisClient.rpop(convKey);
await this.redisClient.del(`message:${deletedMsgKey}`);
}
return 'OK';
} catch (error) {
Logger.error(error);
return 'ERROR';
}
}
public async getHistory(firstUserId: string, secondUserId: string): Promise<MessageDataType[]> {
const convKey = this.createConversationKey(firstUserId, secondUserId);
const msgKeys = await this.redisClient.lrange(convKey, 0, -1);
const messages = await Promise.all(msgKeys.map(key => `message:${key}`).map(key => this.getMessage(key)));
return messages.sort((m1, m2) => new Date(m1.time).valueOf() - new Date(m2.time).valueOf());
}
public async getConversations(userId: string): Promise<ConversationType[]> {
/**
* Getting conversations that had some message history
*/
const conversations = await this.redisClient.keys(`conversation:*${userId}*`);
const userIds = conversations
.map(id => id.split(':')[1])
.map(id => (userId !== id.split('&')[0] ? id.split('&')[0] : id.split('&')[1]));
/**
* Fetch this users
*/
const users = await User.query().select('id', 'first_name', 'last_name').where('id', 'in', userIds);
/**
* Fetch last message of each conversation
*/
const result = await Promise.all(
users.map(async u => {
const convKey = this.createConversationKey(userId, u.id);
const msgKey = await this.redisClient.lindex(convKey, 0);
const lastMessage = await this.getMessage(`message:${msgKey}`);
return {
userId: u.id,
fullname: `${u.first_name} ${u.last_name}`,
lastMessage,
};
})
);
return result;
}
private async getMessage(key: string): Promise<MessageDataType> {
const message = (await this.redisClient.hgetall(key)) as MessageDataType;
return message;
}
// eslint-disable-next-line class-methods-use-this
private createConversationKey(userId1: string, userId2: string): string {
const [id1, id2] = [userId1, userId2].sort();
return `conversation:${id1}&${id2}`;
}
}
export default RedisMessageStore;

View File

@ -0,0 +1,65 @@
import { RedisConnectionContract } from '@ioc:Adonis/Addons/Redis';
import Logger from '@ioc:Adonis/Core/Logger';
import { nanoid } from 'nanoid';
const NOTIFICATION_TTL = 185 * 24 * 60 * 60; // 185 days
type NotificationType = {
id: string;
type: 'SYSTEM' | 'MESSAGE';
content: string;
time: string;
};
class RedisNotificationStore {
public redisClient: RedisConnectionContract;
constructor(redisClient: RedisConnectionContract) {
this.redisClient = redisClient;
}
public async add(userId: string, content: string, type: NotificationType['type']): Promise<NotificationType> {
try {
const id = nanoid();
const key = `notification:${id}`;
const timestamp = Date.now();
const notification = {
id,
content,
time: new Date(timestamp).toISOString(),
type,
};
await this.redisClient.zadd(`user:${userId}`, timestamp, key);
await this.redisClient.multi().hmset(key, notification).expire(key, NOTIFICATION_TTL).exec();
return notification;
} catch (error) {
Logger.error(error);
return Promise.reject(error);
}
}
public async getNotifications(userId: string) {
const keys = await this.redisClient.zrevrangebyscore(`user:${userId}`, '+inf', '-inf');
const notifications = await Promise.all(keys.map(key => this.get(key)));
return notifications;
}
private async get(key: string): Promise<NotificationType> {
const notification = (await this.redisClient.hgetall(key)) as NotificationType;
return notification;
}
public async del(userId: string, keys: string[]): Promise<number> {
if (keys.length === 0) return 0;
const k = keys.map(v => `notification:${v}`);
const count = await this.redisClient.zrem(`user:${userId}`, k);
await this.redisClient.del(k);
return count;
}
}
export default RedisNotificationStore;

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

@ -0,0 +1,143 @@
/* 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',
};
}
private async scanSessionKeys(): Promise<Set<string>> {
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);
return keys;
}
/**
* 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 = await this.scanSessionKeys();
const commands = Array.from(keys).map(key => {
const [, ...id] = key.split('-');
return ['hmget', id.join('-'), 'userId', 'userName', 'connected'];
});
return this.redisClient
.multi(commands)
.exec()
.then(results => {
if (results === null) return [];
return results.map(([err, session]: [Error, (string | null)[]]) => (err ? undefined : this.transform(session)));
});
}
/**
* Returns the unique sessions with connected flag `true`
*/
public async getOnlineSessions() {
const sessions = await this.getSessions();
const online = new Map<string, { userId: string; userName: string }>();
sessions.forEach(s => {
if (s?.connected === true) {
online.set(s.userId, s);
}
});
return Array.from(online);
}
/**
* 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;
}
/**
* Remove all sessions from redis store
*
* @returns Count of deleted sessions
*/
public async destroyAllSessions() {
const keys = await this.scanSessionKeys().then(values =>
Array.from(values).map(v => {
const [, ...key] = v.split('-');
return key.join('-');
})
);
if (keys.length > 0) {
const deleted = await this.redisClient.del(keys);
return deleted;
}
return 0;
}
}
export default RedisSessionStore;

View File

@ -6,7 +6,7 @@ export default class CreateCategoryValidator {
public schema = schema.create({
title: schema.string(),
description: schema.string(),
description: schema.string.optional(),
});
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) {}
public schema = schema.create({
phone_number: schema.string.optional({}, [
phone_number: schema.string.nullableAndOptional({}, [
rules.mobile({
locales: ['ru-RU', 'en-US'],
locale: ['ru-RU', 'en-US'],
strict: true,
}),
]),
vk_id: schema.string.optional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]),
twitter_id: schema.string.optional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]),
telegram_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.nullableAndOptional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]),
telegram_id: schema.string.nullableAndOptional({}, [rules.regex(/^[a-zA-Z0-9_]+$/)]),
});
public messages = {};

View File

@ -5,19 +5,31 @@ export default class UpdateContactsValidator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({
phone_number: schema.string.optional({}, [
phone_number: schema.string.nullableAndOptional({}, [
rules.mobile({
locales: ['ru-RU', 'en-US'],
locale: ['ru-RU', 'en-US'],
strict: true,
}),
rules.unique({ table: 'contacts', column: 'phone_number' }),
]),
vk_id: schema.string.optional({}, [rules.regex(/^([a-zA-Z0-9_]){1,64}$/)]),
twitter_id: schema.string.optional({}, [rules.regex(/(^|[^@\w])@(\w{1,15})\b/)]),
telegram_id: schema.string.optional({}, [rules.regex(/(^|[^@\w])@(\w{1,64})\b/)]),
vk_id: schema.string.nullableAndOptional({}, [
rules.regex(/^([a-zA-Z0-9_]){1,64}$/),
rules.unique({ table: 'contacts', column: 'vk_id' }),
]),
twitter_id: schema.string.nullableAndOptional({}, [
rules.regex(/(^|[^@\w])@(\w{1,15})\b/),
rules.unique({ table: 'contacts', column: 'twitter_id' }),
]),
telegram_id: schema.string.nullableAndOptional({}, [
rules.regex(/(^|[^@\w])@(\w{1,64})\b/),
rules.unique({ table: 'contacts', column: 'telegram_id' }),
]),
});
public messages = {
'phone_number.unique': 'This phone number is not available',
'phone_number.unique': 'This phone number is not available.',
'vk_id.unique': 'This ID is not available.',
'twitter_id.unique': 'Twitter username is not available.',
'telegram_id.unique': 'Telegram username is not available.',
};
}

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,9 +8,10 @@ export default class CreateCourseValidator {
public schema = schema.create({
title: schema.string(),
description: schema.string(),
education_description: schema.string.nullable(),
image: schema.file.optional({
size: '10mb',
extnames: ['jpg', 'jpeg', 'png'],
extnames: ['jpg', 'jpeg', 'png', 'webp'],
}),
teacher_id: schema.string({}, [rules.exists({ table: 'users', column: 'id' })]),
category_id: schema.string({}, [rules.exists({ table: 'categories', column: 'id' })]),

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({
status: schema.enum.optional(Object.values(CourseStatusEnum)),
category_id: schema.string.optional({}, [rules.exists({ table: 'categories', column: 'id' })]),
limit: schema.number.optional(),
});
public messages = {};

View File

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

View File

@ -9,10 +9,14 @@ export default class CreateLessonValidator {
title: schema.string({}, [rules.maxLength(255)]),
description: schema.string(),
duration: schema.date({ format: 'HH:mm:ss' }),
video_url: schema.string({}, [rules.url()]),
video: schema.file.optional({
size: '5000mb',
extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'],
}),
linked_video_url: schema.string.optional([rules.regex(new RegExp('^(http|https|ftp)://'))]),
materials: schema.array.optional().members(
schema.file({
size: '10mb',
size: '100mb',
extnames: ['pdf', 'zip', 'rar', 'doc', 'docx'],
})
),

View File

@ -5,9 +5,21 @@ export default class UpdateLessonValidator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({
course_id: schema.string({}, [rules.exists({ table: 'courses', column: 'id' })]),
title: schema.string({}, [rules.maxLength(255)]),
description: schema.string(),
course_id: schema.string.optional({}, [rules.exists({ table: 'courses', column: 'id' })]),
title: schema.string.optional({}, [rules.maxLength(255)]),
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'],
}),
linked_video_url: schema.string.optional([rules.regex(new RegExp('^(http|https|ftp)://'))]),
materials: schema.array.nullableAndOptional().members(
schema.file({
size: '100mb',
extnames: ['pdf', 'zip', 'rar', 'doc', 'docx'],
})
),
});
public messages = {};

View File

@ -0,0 +1,33 @@
import { schema, rules } from '@ioc:Adonis/Core/Validator';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
export default class UpdateUserInfoValidator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({
about: schema.string.nullableAndOptional(),
phone_number: schema.string.nullableAndOptional({}, [
rules.mobile({ locale: ['ru-RU', 'en-US'], strict: true }),
rules.unique({ table: 'contacts', column: 'phone_number' }),
]),
vk_id: schema.string.nullableAndOptional({}, [
rules.regex(/^([a-zA-Z0-9_]){1,64}$/),
rules.unique({ table: 'contacts', column: 'vk_id' }),
]),
twitter_id: schema.string.nullableAndOptional({}, [
rules.regex(/(^|[^@\w])@(\w{1,15})\b/),
rules.unique({ table: 'contacts', column: 'twitter_id' }),
]),
telegram_id: schema.string.nullableAndOptional({}, [
rules.regex(/(^|[^@\w])@(\w{1,64})\b/),
rules.unique({ table: 'contacts', column: 'telegram_id' }),
]),
});
public messages = {
'phone_number.unique': 'This phone number is not available.',
'vk_id.unique': 'This ID is not available.',
'twitter_id.unique': 'Twitter username is not available.',
'telegram_id.unique': 'Telegram username is not available.',
};
}

View File

@ -8,6 +8,7 @@ export default class UpdateUserValidator {
public schema = schema.create({
first_name: schema.string.optional(),
last_name: schema.string.optional(),
about: schema.string.optional(),
login: schema.string.optional({}, [rules.unique({ table: 'users', column: 'login' })]),
email: schema.string.optional({}, [rules.email(), rules.unique({ table: 'users', column: 'email' })]),
password: schema.string.optional(),

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

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

View File

@ -1,11 +0,0 @@
import Env from '@ioc:Adonis/Core/Env';
import { CloudinaryConfig } from '@ioc:Adonis/Addons/Cloudinary';
const cloudinaryConfig: CloudinaryConfig = {
cloudName: Env.get('CLOUDINARY_CLOUD_NAME'),
apiKey: Env.get('CLOUDINARY_API_KEY'),
apiSecret: Env.get('CLOUDINARY_API_SECRET'),
secure: Env.get('CLOUDINARY_SECURE', true),
};
export default cloudinaryConfig;

View File

@ -43,6 +43,7 @@ const databaseConfig: DatabaseConfig = {
database: Env.get('PG_DB_NAME'),
},
migrations: {
disableRollbacksInProduction: true,
naturalSort: true,
tableName: 'migrations',
},
@ -50,7 +51,7 @@ const databaseConfig: DatabaseConfig = {
paths: ['./database/seeders/MainSeeder'],
},
healthCheck: true,
debug: true,
debug: false,
},
},
};

View File

@ -6,7 +6,7 @@
*/
import Env from '@ioc:Adonis/Core/Env';
import { DriveConfig } from '@ioc:Adonis/Core/Drive';
import { driveConfig } from '@adonisjs/core/build/config';
import Application from '@ioc:Adonis/Core/Application';
/*
@ -18,7 +18,7 @@ import Application from '@ioc:Adonis/Core/Application';
| defined inside the `contracts` directory.
|
*/
const driveConfig: DriveConfig = {
export default driveConfig({
/*
|--------------------------------------------------------------------------
| Default disk
@ -93,15 +93,15 @@ const driveConfig: DriveConfig = {
|**************************************************************************
|
*/
// s3: {
// driver: 's3',
// visibility: 'public',
// key: Env.get('S3_KEY'),
// secret: Env.get('S3_SECRET'),
// region: Env.get('S3_REGION'),
// bucket: Env.get('S3_BUCKET'),
// endpoint: Env.get('S3_ENDPOINT'),
// },
s3: {
driver: 's3',
visibility: 'private',
key: Env.get('S3_KEY'),
secret: Env.get('S3_SECRET'),
region: Env.get('S3_REGION'),
bucket: Env.get('S3_BUCKET'),
endpoint: Env.get('S3_ENDPOINT'),
},
/*
|--------------------------------------------------------------------------
@ -143,6 +143,4 @@ const driveConfig: DriveConfig = {
// usingUniformAcl: false
// },
},
};
export default driveConfig;
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import Factory from '@ioc:Adonis/Lucid/Factory';
import Factory, { FactoryContextContract } from '@ioc:Adonis/Lucid/Factory';
import { string } from '@ioc:Adonis/Core/Helpers';
/**
@ -18,7 +18,7 @@ import Role from 'App/Models/Role';
import User from 'App/Models/User';
import { DateTime } from 'luxon';
const makeFakeUser = (faker: Faker.FakerStatic, loginPrefix?: string) => {
const makeFakeUser = (faker: FactoryContextContract['faker'], loginPrefix?: string) => {
return {
first_name: faker.name.firstName(),
last_name: faker.name.lastName(),
@ -56,7 +56,7 @@ export const StudentFactory = Factory.define(User, ({ faker }) => makeFakeUser(f
export const LessonContentFactory = Factory.define(LessonContent, ({ faker }) => {
return {
video_url: `https://www.youtube.com/embed/${faker.datatype.string(10)}`,
body: `${faker.lorem.sentences(40)}`,
};
}).build();
@ -87,6 +87,7 @@ export const CourseFactory = Factory.define(Course, ({ faker }) => {
return {
title: faker.lorem.sentence(8),
description: faker.lorem.sentence(20),
education_description: faker.lorem.sentence(15),
status: CourseStatusEnum.PUBLISHED,
};
})

View File

@ -8,9 +8,11 @@ export default class Users extends BaseSchema {
table.string('id', 21).primary();
table.string('first_name', 24).notNullable();
table.string('last_name', 24).notNullable();
table.text('about');
table.string('login', 128).notNullable().unique();
table.string('email').notNullable().unique();
table.string('password').notNullable();
table.timestamp('last_login', { useTz: true });
table.timestamp('created_at', { useTz: true });
table.timestamp('updated_at', { useTz: true });
});

View File

@ -14,6 +14,7 @@ export default class Courses extends BaseSchema {
table.json('image');
table.string('title').notNullable();
table.string('description').notNullable();
table.string('education_description', 250);
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.integer('color_id').unsigned().references('colors.id');

View File

@ -11,6 +11,7 @@ export default class Lessons extends BaseSchema {
table.integer('display_order').notNullable();
table.integer('color_id').unsigned().references('colors.id');
table.string('description');
table.text('linked_video_url').nullable();
table.time('duration').notNullable();
table.timestamp('created_at', { useTz: true });
table.timestamp('updated_at', { useTz: true });

View File

@ -7,7 +7,7 @@ export default class LessonContents extends BaseSchema {
this.schema.createTable(this.tableName, table => {
table.increments('id').primary();
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('updated_at', { useTz: true });
});

View File

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

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

View File

@ -7,7 +7,13 @@ export default class CourseSeeder extends BaseSeeder {
public async run() {
this.CourseFactory = CourseFactory;
await this.CourseFactory.with('lessons', 10, lessonFactory => lessonFactory.with('content'))
const LESSONS_COUNT = 10;
await this.CourseFactory.with('lessons', LESSONS_COUNT, lessonFactory =>
lessonFactory
.with('content')
.merge(new Array(LESSONS_COUNT).fill(LESSONS_COUNT).map((_, i) => ({ display_order: i + 1 })))
)
.with('category')
.with('teacher')
.createMany(3);

View File

@ -11,23 +11,48 @@ export default class UserSeeder extends BaseSeeder {
*/
const roles = {
admin: await Role.findByOrFail('slug', 'admin'),
teacher: await Role.findByOrFail('slug', 'teacher'),
student: await Role.findByOrFail('slug', 'student'),
};
/**
* Administrator
*/
const userAdmin = await User.create({
const administrator = await User.create({
first_name: 'John',
last_name: 'Doe',
login: 'admin',
password: '123456',
email: 'administrator@example.com',
});
await userAdmin.related('roles').attach([roles.admin.id]);
await administrator.related('roles').attach([roles.admin.id]);
/**
* Students
* Teacher
*/
const teacher = await User.create({
first_name: 'Hanna',
last_name: 'Liv',
login: 'teacher',
password: '123456',
email: 'teacher@example.com',
});
await teacher.related('roles').attach([roles.teacher.id]);
/**
* Student
*/
const student = await User.create({
first_name: 'Ylfa',
last_name: 'Erna',
login: 'student',
password: '123456',
email: 'student@example.com',
});
await student.related('roles').attach([roles.student.id]);
/**
* Student list
*/
await StudentFactory.with('contacts').createMany(20);
}

13
env.ts
View File

@ -15,25 +15,30 @@
import Env from '@ioc:Adonis/Core/Env';
export default Env.rules({
HOST: Env.schema.string({ format: 'host' }),
PORT: Env.schema.number(),
APP_KEY: Env.schema.string(),
APP_NAME: Env.schema.string(),
NODE_ENV: Env.schema.enum(['development', 'production', 'testing'] as const),
DRIVE_DISK: Env.schema.enum(['local'] as const),
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
DRIVE_DISK: Env.schema.enum(['local', 's3'] as const),
PG_HOST: Env.schema.string({ format: 'host' }),
PG_PORT: Env.schema.number(),
PG_USER: Env.schema.string(),
PG_PASSWORD: Env.schema.string.optional(),
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_PORT: Env.schema.number(),
REDIS_PASSWORD: Env.schema.string.optional(),
MAILER: Env.schema.enum(['mailgun', 'smtp'] as const),
SMTP_HOST: Env.schema.string({ format: 'host' }),
SMTP_PORT: Env.schema.number(),
SMTP_USERNAME: Env.schema.string(),
SMTP_PASSWORD: Env.schema.string(),
MAILGUN_API_KEY: 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(),
});

16133
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,11 +0,0 @@
/* eslint-disable global-require */
import Route from '@ioc:Adonis/Core/Route';
Route.group(() => {
/**
* v1
*/
require('./apis/v1/index');
})
.prefix('api')
.as('api');

View File

@ -1,68 +0,0 @@
/* eslint-disable global-require */
import Route from '@ioc:Adonis/Core/Route';
Route.group(() => {
Route.get('/', () => {
return 'Backend API.';
}).as('index');
/**
*
* Auth module
*
*/
require('./auth');
Route.group(() => {
/**
*
* Health check
*
*/
require('./health');
/**
*
* Users module
*
*/
require('./users');
/**
*
* Me module
*
*/
require('./me');
/**
*
* Courses module
*
*/
require('./courses');
/**
*
* Lessons module
*
*/
require('./lessons');
/**
*
* Images module
*
*/
require('./images');
/**
*
* Categories module
*
*/
require('./categories');
}).middleware('auth');
})
.prefix('v1')
.as('v1');

View File

@ -1,29 +0,0 @@
/* eslint-disable global-require */
import Route from '@ioc:Adonis/Core/Route';
/*
|--------------------------------------------------------------------------
| Routes
|--------------------------------------------------------------------------
|
| This file is dedicated for defining HTTP routes. A single file is enough
| for majority of projects, however you can define routes in different
| files and just make sure to import them inside this file. For example
|
| Define routes in following two files
| ├── start/routes/cart.ts
| ├── start/routes/customer.ts
|
| and then import them inside `start/routes.ts` as follows
|
| import './routes/cart'
| import './routes/customer'
|
*/
Route.group(() => {
/**
* Api routes
*/
require('./api');
});

View File

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

View File

@ -6,11 +6,6 @@
*/
import Bouncer from '@ioc:Adonis/Addons/Bouncer';
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
import RoleHelper from 'App/Helpers/RoleHelper';
import Lesson from 'App/Models/Lesson';
import Role from 'App/Models/Role';
import User from 'App/Models/User';
/*
|--------------------------------------------------------------------------
@ -34,22 +29,7 @@ import User from 'App/Models/User';
| NOTE: Always export the "actions" const from this file
|****************************************************************
*/
export const { actions } = Bouncer.define('manageUserRole', async (user: User, role: Role) => {
await user.load('roles');
return !(
(role.slug === RoleEnum.ADMIN || role.slug === RoleEnum.TEACHER) &&
!RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN])
);
}).define('viewLessonContent', async (user: User, lesson: Lesson) => {
await user.load(loader => loader.load('roles').load('courses'));
await lesson.load('course');
if (!RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN, RoleEnum.TEACHER])) {
return !!user.courses.find(course => course.id === lesson.course.id);
}
return true;
});
export const { actions } = Bouncer;
/*
|--------------------------------------------------------------------------
@ -74,4 +54,7 @@ export const { actions } = Bouncer.define('manageUserRole', async (user: User, r
| NOTE: Always export the "policies" const from this file
|****************************************************************
*/
export const { policies } = Bouncer.registerPolicies({});
export const { policies } = Bouncer.registerPolicies({
LessonPolicy: () => import('App/Policies/LessonPolicy'),
RolePolicy: () => import('App/Policies/RolePolicy'),
});

19
start/routes.ts Normal file
View File

@ -0,0 +1,19 @@
import './routes/index';
/**
* API v1
*/
import './routes/apis/v1/auth';
import './routes/apis/v1/categories';
import './routes/apis/v1/chat';
import './routes/apis/v1/courses';
import './routes/apis/v1/health';
import './routes/apis/v1/lessons';
import './routes/apis/v1/me';
import './routes/apis/v1/stat';
import './routes/apis/v1/users';
/**
* Other routes
*/
import './routes/videos';

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More