From 46263fa341b947a83bd27dc5eb1f2909c736c841 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Mon, 22 Nov 2021 22:41:46 +0300 Subject: [PATCH 1/6] removed cloudinary service --- .adonisrc.json | 1 - .env.example | 4 -- app/Repositories/ImageRepository.ts | 32 -------------- app/Services/CloudinaryService.ts | 67 ----------------------------- contracts/cloudinary.ts | 42 ------------------ env.ts | 4 -- providers/CloudinaryProvider.ts | 14 ------ 7 files changed, 164 deletions(-) delete mode 100644 app/Services/CloudinaryService.ts delete mode 100644 contracts/cloudinary.ts delete mode 100644 providers/CloudinaryProvider.ts diff --git a/.adonisrc.json b/.adonisrc.json index a960fa8..e25a497 100644 --- a/.adonisrc.json +++ b/.adonisrc.json @@ -27,7 +27,6 @@ "preloads": ["./routes/index", "./start/kernel", "./start/event", "./start/bouncer"], "providers": [ "./providers/AppProvider", - "./providers/CloudinaryProvider", "@adonisjs/core", "@adonisjs/lucid", "@adonisjs/auth", diff --git a/.env.example b/.env.example index 84c72fe..2dbe8bf 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,6 @@ REDIS_CONNECTION=local REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= -CLOUDINARY_CLOUD_NAME= -CLOUDINARY_API_KEY= -CLOUDINARY_API_SECRET= -CLOUDINARY_SECURE=true SMTP_HOST=localhost SMTP_PORT=587 SMTP_USERNAME= diff --git a/app/Repositories/ImageRepository.ts b/app/Repositories/ImageRepository.ts index 281bd1e..dc1ad1d 100644 --- a/app/Repositories/ImageRepository.ts +++ b/app/Repositories/ImageRepository.ts @@ -1,5 +1,4 @@ import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser'; -// import CloudinaryService from '@ioc:Adonis/Addons/Cloudinary'; import Drive from '@ioc:Adonis/Core/Drive'; import Image from 'App/Models/Image'; @@ -56,35 +55,4 @@ export default class ImageRepository { return null; } - - /** - * Create image in cloudinary cloud - * - * @param file File - * @param publicId Image public id - * @returns Created image - */ - // public async createInCloudinaryCloud(file: MultipartFileContract, publicId: string | undefined): Promise { - // const response = await CloudinaryService.upload(file, publicId); - // const image = await this.Image.create({ name: publicId, path: response.secure_url, ext: response.format }); - // return image; - // } - - /** - * Delete image from cloudinary cloud - * - * @param id Image id - * @returns Result or null - */ - // public async deleteFormCloudinaryCloud(id: number): Promise { - // const image = await this.Image.query().where('id', id).first(); - - // if (image) { - // await CloudinaryService.destroy(image.name); - // await image.delete(); - // return image; - // } - - // return null; - // } } diff --git a/app/Services/CloudinaryService.ts b/app/Services/CloudinaryService.ts deleted file mode 100644 index f65bbfe..0000000 --- a/app/Services/CloudinaryService.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as cloudinary from 'cloudinary'; -import { UploadApiResponse, UploadApiOptions, ResponseCallback, ResourceType, DeliveryType } from 'cloudinary'; -import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser'; -import { CloudinaryConfig, CloudinaryInterface } from '@ioc:Adonis/Addons/Cloudinary'; - -export default class CloudinaryService implements CloudinaryInterface { - private readonly config: CloudinaryConfig; - - private cloudinary: typeof cloudinary; - - constructor(config: CloudinaryConfig) { - /** - * Cloudinary config - */ - cloudinary.v2.config({ - api_key: config.apiKey, - api_secret: config.apiSecret, - cloud_name: config.cloudName, - secure: config.secure, - }); - - this.cloudinary = cloudinary; - this.config = config; - } - - public async upload( - file: MultipartFileContract, - publicId: string | undefined = undefined, - uploadOptions?: UploadApiOptions, - callback?: ResponseCallback - ): Promise { - const path = CloudinaryService.getPathFromFile(file); - const response = await this.cloudinary.v2.uploader.upload( - path, - { public_id: publicId, ...uploadOptions }, - callback - ); - - return response; - } - - public async destroy( - publicId: string, - options?: { - resource_type?: ResourceType; - type?: DeliveryType; - invalidate?: boolean; - } - ): Promise { - const response = await this.cloudinary.v2.uploader.destroy(publicId, options); - return response; - } - - public getConfig(): CloudinaryConfig { - return this.config; - } - - public static getPathFromFile(file: MultipartFileContract): string { - const path = file.tmpPath ?? file.filePath; - - if (!path) { - throw new Error('Cannot get file path from file'); - } - - return path; - } -} diff --git a/contracts/cloudinary.ts b/contracts/cloudinary.ts deleted file mode 100644 index 26ae909..0000000 --- a/contracts/cloudinary.ts +++ /dev/null @@ -1,42 +0,0 @@ -declare module '@ioc:Adonis/Addons/Cloudinary' { - import { - ResponseCallback, - TransformationOptions, - UploadApiOptions, - UploadApiResponse, - ResourceType, - DeliveryType, - } from 'cloudinary'; - import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser'; - - export interface CloudinaryConfig { - cloudName: string; - apiKey: string; - apiSecret: string; - secure: boolean; - [key: string]: TransformationOptions; - } - - export interface CloudinaryInterface { - upload( - file: MultipartFileContract, - publicId: string | undefined, - uploadOptions?: UploadApiOptions, - callback?: ResponseCallback - ): Promise; - - destroy( - publicId: string, - options?: { - resource_type?: ResourceType; - type?: DeliveryType; - invalidate?: boolean; - } - ): Promise; - - getConfig(): CloudinaryConfig; - } - - const CloudinaryService: CloudinaryInterface; - export default CloudinaryService; -} diff --git a/env.ts b/env.ts index fc14b74..aed92d2 100644 --- a/env.ts +++ b/env.ts @@ -36,8 +36,4 @@ export default Env.rules({ SMTP_PASSWORD: Env.schema.string(), MAILGUN_API_KEY: Env.schema.string(), MAILGUN_DOMAIN: Env.schema.string(), - CLOUDINARY_CLOUD_NAME: Env.schema.string(), - CLOUDINARY_API_KEY: Env.schema.string(), - CLOUDINARY_API_SECRET: Env.schema.string(), - CLOUDINARY_SECURE: Env.schema.boolean(), }); diff --git a/providers/CloudinaryProvider.ts b/providers/CloudinaryProvider.ts deleted file mode 100644 index 6508067..0000000 --- a/providers/CloudinaryProvider.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApplicationContract } from '@ioc:Adonis/Core/Application'; -import CloudinaryService from 'App/Services/CloudinaryService'; - -export default class CloudinaryProvider { - constructor(protected app: ApplicationContract) {} - - public register() { - this.app.container.singleton('Adonis/Addons/Cloudinary', () => { - const config = this.app.container.resolveBinding('Adonis/Core/Config').get('cloudinary', {}); - - return new CloudinaryService(config); - }); - } -} From 4ac06f2c24b550616bf4677959b9c909dd17a316 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Tue, 23 Nov 2021 23:51:24 +0300 Subject: [PATCH 2/6] added order column --- app/Models/Lesson.ts | 3 +++ database/migrations/1628016919501_lessons.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/app/Models/Lesson.ts b/app/Models/Lesson.ts index 8c1cd5f..26ad5e8 100644 --- a/app/Models/Lesson.ts +++ b/app/Models/Lesson.ts @@ -17,6 +17,9 @@ export default class Lesson extends BaseModel { @column() public title: string; + @column() + public display_order: number; + @column() public description: string; diff --git a/database/migrations/1628016919501_lessons.ts b/database/migrations/1628016919501_lessons.ts index 7545313..6c65853 100644 --- a/database/migrations/1628016919501_lessons.ts +++ b/database/migrations/1628016919501_lessons.ts @@ -8,6 +8,7 @@ export default class Lessons extends BaseSchema { table.string('id', 21).primary(); table.string('course_id', 21).unsigned().references('courses.id').onDelete('CASCADE'); table.string('title').notNullable(); + table.integer('display_order').notNullable(); table.integer('color_id').unsigned().references('colors.id'); table.string('description'); table.time('duration').notNullable(); From 14122905d6e70c78b6fb396ce8ae3baa8661972f Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Wed, 24 Nov 2021 22:16:25 +0300 Subject: [PATCH 3/6] feat: added save-order route --- .../Http/Api/v1/LessonsController.ts | 20 ++++++++++++++++ app/Models/Lesson.ts | 2 +- app/Repositories/CourseRepository.ts | 2 +- app/Services/LessonService.ts | 23 +++++++++++++++++++ database/factories/index.ts | 1 + database/migrations/1617351321869_colors.ts | 3 ++- database/migrations/1627300211260_courses.ts | 4 ++-- database/seeders/Course.ts | 2 +- routes/apis/v1/lessons.ts | 3 +++ 9 files changed, 54 insertions(+), 6 deletions(-) diff --git a/app/Controllers/Http/Api/v1/LessonsController.ts b/app/Controllers/Http/Api/v1/LessonsController.ts index 3ac125c..90a9101 100644 --- a/app/Controllers/Http/Api/v1/LessonsController.ts +++ b/app/Controllers/Http/Api/v1/LessonsController.ts @@ -1,6 +1,7 @@ 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'; /** * Services @@ -137,6 +138,25 @@ export default class LessonsController extends BaseController { return this.sendResponse(ctx, data, message, status); } + + /** + * Save order of lessons + * POST /lessons/save-order + */ + // eslint-disable-next-line class-methods-use-this + public async saveOrder(ctx: HttpContextContract) { + const saveOrderSchema = schema.create({ + ids: schema.array().members(schema.string()), + }); + const payload = await ctx.request.validate({ schema: saveOrderSchema }); + const { data, message, status, success, error } = await this.lessonService.updateOrder(payload.ids); + + if (!success && error) { + throw new Exception(message, status, error.code); + } + + return this.sendResponse(ctx, data, message, status); + } } new Ioc().make(LessonsController); diff --git a/app/Models/Lesson.ts b/app/Models/Lesson.ts index 26ad5e8..a7f6b22 100644 --- a/app/Models/Lesson.ts +++ b/app/Models/Lesson.ts @@ -17,7 +17,7 @@ export default class Lesson extends BaseModel { @column() public title: string; - @column() + @column({ serializeAs: null }) public display_order: number; @column() diff --git a/app/Repositories/CourseRepository.ts b/app/Repositories/CourseRepository.ts index a9fa990..28fb993 100644 --- a/app/Repositories/CourseRepository.ts +++ b/app/Repositories/CourseRepository.ts @@ -82,7 +82,7 @@ export default class CourseRepository { .preload('teacher') .preload('color') .preload('category') - .preload('lessons', q => q.preload('color')) + .preload('lessons', q => q.preload('color').orderBy('display_order', 'asc')) .preload('students') .where('id', id) .first(); diff --git a/app/Services/LessonService.ts b/app/Services/LessonService.ts index b3401cd..2e226e7 100644 --- a/app/Services/LessonService.ts +++ b/app/Services/LessonService.ts @@ -6,6 +6,7 @@ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; */ import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum'; import IResponse from 'App/Datatypes/Interfaces/IResponse'; +import Lesson from 'App/Models/Lesson'; import LessonMaterial from 'App/Models/LessonMaterial'; /** @@ -264,6 +265,28 @@ export default class LessonService { data: lesson.content, }; } + + // eslint-disable-next-line class-methods-use-this + public async updateOrder(ids: string[]): Promise> { + /** + * Update order of lessons + */ + // TODO can be transformed into one query + await Promise.all( + ids.map(async (id, i) => { + await Lesson.query() + .where('id', id) + .update({ display_order: i + 1 }); + }) + ); + + return { + success: true, + status: HttpStatusEnum.OK, + message: 'Order updated.', + data: {}, + }; + } } new Ioc().make(LessonService); diff --git a/database/factories/index.ts b/database/factories/index.ts index 18f2dc0..1678e0c 100644 --- a/database/factories/index.ts +++ b/database/factories/index.ts @@ -64,6 +64,7 @@ export const LessonFactory = Factory.define(Lesson, ({ faker }) => { return { title: faker.lorem.sentence(6), description: faker.lorem.sentence(7), + display_order: 1, duration: DateTime.fromObject({ hour: faker.datatype.number(2), minute: faker.datatype.number({ min: 30, max: 59 }), diff --git a/database/migrations/1617351321869_colors.ts b/database/migrations/1617351321869_colors.ts index b531583..f26702f 100644 --- a/database/migrations/1617351321869_colors.ts +++ b/database/migrations/1617351321869_colors.ts @@ -11,7 +11,7 @@ export default class Colors extends BaseSchema { .enu('name', Object.keys(ColorEnum), { useNative: true, enumName: 'color_name_enum', - existingType: true, + existingType: false, schemaName: 'public', }) .notNullable(); @@ -20,6 +20,7 @@ export default class Colors extends BaseSchema { } public async down() { + await this.schema.raw('DROP TYPE IF EXISTS "color_name_enum" CASCADE'); this.schema.dropTable(this.tableName); } } diff --git a/database/migrations/1627300211260_courses.ts b/database/migrations/1627300211260_courses.ts index be8408e..9657c28 100644 --- a/database/migrations/1627300211260_courses.ts +++ b/database/migrations/1627300211260_courses.ts @@ -21,7 +21,7 @@ export default class Courses extends BaseSchema { .enu('status', Object.values(CourseStatusEnum), { useNative: true, enumName: 'course_status_enum', - existingType: true, + existingType: false, schemaName: 'public', }) .notNullable(); @@ -31,7 +31,7 @@ export default class Courses extends BaseSchema { } public async down() { - await this.schema.raw('DROP TYPE IF EXISTS "course_status" CASCADE'); + await this.schema.raw('DROP TYPE IF EXISTS "course_status_enum" CASCADE'); this.schema.dropTable(this.tableName); } } diff --git a/database/seeders/Course.ts b/database/seeders/Course.ts index 4a21c77..78ca18d 100644 --- a/database/seeders/Course.ts +++ b/database/seeders/Course.ts @@ -7,7 +7,7 @@ export default class CourseSeeder extends BaseSeeder { public async run() { this.CourseFactory = CourseFactory; - await this.CourseFactory.with('lessons', 2, lessonFactory => lessonFactory.with('content')) + await this.CourseFactory.with('lessons', 10, lessonFactory => lessonFactory.with('content')) .with('category') .with('teacher') .createMany(3); diff --git a/routes/apis/v1/lessons.ts b/routes/apis/v1/lessons.ts index e8d14ab..f2fa3f4 100644 --- a/routes/apis/v1/lessons.ts +++ b/routes/apis/v1/lessons.ts @@ -3,6 +3,9 @@ import Route from '@ioc:Adonis/Core/Route'; Route.group(() => { Route.get('/', 'Api/v1/LessonsController.list').middleware('role:admin,teacher,student').as('lessons.list'); Route.post('/', 'Api/v1/LessonsController.create').middleware('role:admin,teacher').as('lessons.create'); + Route.post('/save-order', 'Api/v1/LessonsController.saveOrder') + .middleware('role:admin,teacher') + .as('lessons.update-order'); Route.get('/:id', 'Api/v1/LessonsController.show').middleware('role:admin,teacher,student').as('lessons.show'); Route.delete('/:id', 'Api/v1/LessonsController.delete').middleware('role:admin,teacher').as('lessons.delete'); Route.put('/:id', 'Api/v1/LessonsController.update').middleware('role:admin,teacher').as('lessons.update'); From a4adf27831d457aee18d21b7ca5657639c02e849 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Wed, 24 Nov 2021 23:21:16 +0300 Subject: [PATCH 4/6] feat: assign random color to lesson & deleted some colors in enum type --- app/Datatypes/Enums/ColorEnum.ts | 9 --------- app/Models/Lesson.ts | 10 ++++++++++ app/Repositories/LessonRepository.ts | 3 +++ database/factories/index.ts | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/Datatypes/Enums/ColorEnum.ts b/app/Datatypes/Enums/ColorEnum.ts index a71f6e7..4ee5616 100644 --- a/app/Datatypes/Enums/ColorEnum.ts +++ b/app/Datatypes/Enums/ColorEnum.ts @@ -11,15 +11,6 @@ enum ColorEnum { Purple = '#800080', Indigo = '#4B0082', Teal = '#008080', - LightGray = '#D3D3D3', - LightSlateGray = '#778899', - LightGreen = '#90EE90', - LightSeaGreen = '#20B2AA', - LightCoral = '#FFA07A', - LightCyan = '#E0FFFF', - LightSteelBlue = '#B0C4DE', - LightBlue = '#ADD8E6', - LightSkyBlue = '#87CEFA', MediumVioletRed = '#C71585', MediumOrchid = '#BA55D3', MediumPurple = '#9370DB', diff --git a/app/Models/Lesson.ts b/app/Models/Lesson.ts index a7f6b22..30e79b2 100644 --- a/app/Models/Lesson.ts +++ b/app/Models/Lesson.ts @@ -2,6 +2,7 @@ import { DateTime } from 'luxon'; import { nanoid } from 'nanoid'; import { BaseModel, beforeCreate, BelongsTo, belongsTo, column, 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 LessonContent from './LessonContent'; @@ -58,4 +59,13 @@ export default class Lesson extends BaseModel { public static assignLessonId(lesson: Lesson) { lesson.id = nanoid(); } + + @beforeCreate() + public static async assignRandomColor(course: Course) { + const color = await ColorHelper.generateRandomColor(); + + if (color) { + course.color_id = color.id; + } + } } diff --git a/app/Repositories/LessonRepository.ts b/app/Repositories/LessonRepository.ts index 9eff7fc..437144e 100644 --- a/app/Repositories/LessonRepository.ts +++ b/app/Repositories/LessonRepository.ts @@ -72,10 +72,13 @@ export default class LessonRepository { * @returns Lesson */ public async create(course: Course, data: CreateLessonValidator['schema']['props']): Promise { + await course.loadCount('lessons'); + const lesson = await this.Lesson.create({ title: data.title, description: data.description, duration: data.duration.toFormat('HH:mm:ss'), + display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1, }); /** diff --git a/database/factories/index.ts b/database/factories/index.ts index 1678e0c..b1c2c90 100644 --- a/database/factories/index.ts +++ b/database/factories/index.ts @@ -70,7 +70,7 @@ export const LessonFactory = Factory.define(Lesson, ({ faker }) => { minute: faker.datatype.number({ min: 30, max: 59 }), second: 0, }).toFormat('HH:mm:ss'), - color_id: faker.datatype.number({ min: 1, max: 25 }), + color_id: faker.datatype.number({ min: 1, max: 15 }), }; }) .relation('content', () => LessonContentFactory) From ef2cc110419f597232ef4075938aff42f04ef5f5 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Thu, 25 Nov 2021 20:35:17 +0300 Subject: [PATCH 5/6] refactor: optimized query --- app/Services/LessonService.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/Services/LessonService.ts b/app/Services/LessonService.ts index 2e226e7..7a39a07 100644 --- a/app/Services/LessonService.ts +++ b/app/Services/LessonService.ts @@ -1,12 +1,12 @@ import { inject, Ioc } from '@adonisjs/core/build/standalone'; import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; +import Database from '@ioc:Adonis/Lucid/Database'; /** * Datatypes */ import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum'; import IResponse from 'App/Datatypes/Interfaces/IResponse'; -import Lesson from 'App/Models/Lesson'; import LessonMaterial from 'App/Models/LessonMaterial'; /** @@ -271,14 +271,18 @@ export default class LessonService { /** * Update order of lessons */ - // TODO can be transformed into one query - await Promise.all( - ids.map(async (id, i) => { - await Lesson.query() - .where('id', id) - .update({ display_order: i + 1 }); - }) - ); + const params = ids + .map((id, i) => ({ id, i: i + 1 })) + .reduce((result: Array, { id, i }) => result.concat(id, i), []); + + const query = ` + UPDATE lessons + SET display_order = t.display_order + FROM (VALUES ${ids.map(() => `(?, ??)`).join(',')}) as t(id, display_order) + WHERE t.id = lessons.id + `; + + await Database.rawQuery(query, params); return { success: true, From e682acc4f61b820311afdff050472e258d3efd56 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Thu, 25 Nov 2021 21:17:13 +0300 Subject: [PATCH 6/6] feat: added changelog file --- CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7036bcf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +- API deployment with Docker and Nginx web server. + +## [1.0.0] - 2021-11-25 + +### Added + +- Base CRUD operations on models +- Displaying a background by API for each course +- Authentication with API tokens +- Email updating with verification +- Updating the password of an authenticated user +- Downloading materials from each lesson +- Implemented simple role-based route access control +- Course likes system +- Access to lesson materials +- Lesson ordering diff --git a/README.md b/README.md index b194173..a6fa9e9 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# ... +# About API + +...