diff --git a/.adonisrc.json b/.adonisrc.json index e444ba7..5ada5b9 100644 --- a/.adonisrc.json +++ b/.adonisrc.json @@ -24,12 +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", @@ -42,9 +37,7 @@ "@adonisjs/bouncer", "@adonisjs/drive-s3" ], - "aceProviders": [ - "@adonisjs/repl" - ], + "aceProviders": ["@adonisjs/repl"], "metaFiles": [ { "pattern": "resources/views/**/*.edge", diff --git a/CHANGELOG.md b/CHANGELOG.md index b259d72..c0729d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ - API deployment with Docker and Nginx web server. +## [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 diff --git a/app/Controllers/Http/Api/v1/ChatController.ts b/app/Controllers/Http/Api/v1/ChatController.ts new file mode 100644 index 0000000..7f9e3ad --- /dev/null +++ b/app/Controllers/Http/Api/v1/ChatController.ts @@ -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 = { + success: true, + status: HttpStatusEnum.OK, + message: 'Fetched conversations.', + data, + }; + + return this.sendResponse(ctx, result.data, result.message, result.status); + } +} + +new Ioc().make(ChatController); diff --git a/app/Controllers/Http/Api/v1/MeController.ts b/app/Controllers/Http/Api/v1/MeController.ts index c9db38e..2216cf4 100644 --- a/app/Controllers/Http/Api/v1/MeController.ts +++ b/app/Controllers/Http/Api/v1/MeController.ts @@ -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'; @@ -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); diff --git a/app/Controllers/Ws/OnlineController.ts b/app/Controllers/Ws/OnlineController.ts index 9f59b86..59b132b 100644 --- a/app/Controllers/Ws/OnlineController.ts +++ b/app/Controllers/Ws/OnlineController.ts @@ -23,9 +23,8 @@ export default class OnlineController { this.socket.on('disconnect', reason => this.onDisconnected(reason)); } - public sendOnlineCount(online: number) { - this.socket.emit('user:online', online); - this.socket.broadcast.emit('user:online', online); + public sendOnline(online: [string, { userId: string; userName: string }][]) { + this.io.sockets.emit('user:online', online); } public setUserOnline() { @@ -37,8 +36,8 @@ export default class OnlineController { */ this.sessionStore .saveSession(sessionId, { userId, userName, connected: true }) - .then(() => this.sessionStore.getOnlineSessionsCount()) - .then(online => this.sendOnlineCount(online)); + .then(() => this.sessionStore.getOnlineSessions()) + .then(online => this.sendOnline(online)); } } @@ -54,7 +53,7 @@ export default class OnlineController { */ this.sessionStore .saveSession(sessionId, { userId, userName, connected: false }) - .then(() => this.sessionStore.getOnlineSessionsCount()) + .then(() => this.sessionStore.getOnlineSessions()) .then(online => this.socket.broadcast.emit('user:online', online)); } } diff --git a/app/Middleware/Role.ts b/app/Middleware/Role.ts index ac9b78b..a6ca473 100644 --- a/app/Middleware/Role.ts +++ b/app/Middleware/Role.ts @@ -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, 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(); } } diff --git a/app/Models/User.ts b/app/Models/User.ts index 695fa6b..5d9cc55 100644 --- a/app/Models/User.ts +++ b/app/Models/User.ts @@ -70,6 +70,12 @@ export default class User extends BaseModel { }) public lessons_progress: HasMany; + @column() + public about: string | null; + + @column.dateTime() + public last_login: DateTime | null; + @column.dateTime({ autoCreate: true }) public createdAt: DateTime; diff --git a/app/Repositories/UserRepository.ts b/app/Repositories/UserRepository.ts index ed9b30e..1f7ee8a 100644 --- a/app/Repositories/UserRepository.ts +++ b/app/Repositories/UserRepository.ts @@ -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; @@ -159,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 * diff --git a/app/Services/AuthService.ts b/app/Services/AuthService.ts index 6b6e47d..b3bdcc2 100644 --- a/app/Services/AuthService.ts +++ b/app/Services/AuthService.ts @@ -66,12 +66,21 @@ 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 toke to response + */ ctx.response.cookie('token', token.token); + /** + * Save last login date + */ + await this.userRepository.updateLastLogin(user.id); + return { success: true, status: HttpStatusEnum.OK, diff --git a/app/Services/MeService.ts b/app/Services/MeService.ts index 5fb3854..a0a8595 100644 --- a/app/Services/MeService.ts +++ b/app/Services/MeService.ts @@ -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 { @@ -55,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, + }, }; } @@ -247,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'], @@ -279,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 { + 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 { + 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); diff --git a/app/Services/StatService.ts b/app/Services/StatService.ts index d50ff32..8815509 100644 --- a/app/Services/StatService.ts +++ b/app/Services/StatService.ts @@ -20,7 +20,7 @@ export default class StatService { ` ); - const online = await Ws.sessionStore.getOnlineSessionsCount(); + const online = await Ws.sessionStore.getOnlineSessions(); return { success: true, @@ -28,7 +28,7 @@ export default class StatService { message: 'Fetched stat.', data: { ...count.rows[0], - online, + online: online.length, }, }; } diff --git a/app/Services/WsService.ts b/app/Services/WsService.ts index 2cc313f..5d6370d 100644 --- a/app/Services/WsService.ts +++ b/app/Services/WsService.ts @@ -1,6 +1,7 @@ import { Server } from 'socket.io'; import Redis from '@ioc:Adonis/Addons/Redis'; import AdonisServer from '@ioc:Adonis/Core/Server'; +import { DateTime } from 'luxon'; /** * Controllers @@ -11,7 +12,13 @@ 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 { /** @@ -19,7 +26,18 @@ export interface ServerToClientEvents { */ 'user:session': (data: { sessionId: string | undefined; userId: string | undefined }) => void; 'user:connected': (data: { userId: string; userName: string }) => void; - 'user:online': (data: number) => 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 { @@ -28,6 +46,16 @@ export interface ClientToServerEvents { */ '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 {} @@ -43,6 +71,10 @@ class WsService { public sessionStore: RedisSessionStore; + public messageStore: RedisMessageStore; + + public notificationStore: RedisNotificationStore; + public booted = false; public boot() { @@ -57,25 +89,69 @@ class WsService { credentials: true, }, }); - this.sessionStore = new RedisSessionStore(Redis.connection('session')); - this.setupMiddlewares(); - this.listen(); + /** + * 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', socket => { + this.io.on('connection', async socket => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const onlineController = new OnlineController(socket, this.io, this.sessionStore); const { sessionId, userId, userName } = socket.data; const isExistSocketData = !!(sessionId && userId && userName); + 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 */ diff --git a/app/Store/MessageStore.ts b/app/Store/MessageStore.ts new file mode 100644 index 0000000..a15e71e --- /dev/null +++ b/app/Store/MessageStore.ts @@ -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 { + 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 { + /** + * 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 { + 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; diff --git a/app/Store/NotificationStore.ts b/app/Store/NotificationStore.ts new file mode 100644 index 0000000..dd98e57 --- /dev/null +++ b/app/Store/NotificationStore.ts @@ -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 { + 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 { + const notification = (await this.redisClient.hgetall(key)) as NotificationType; + return notification; + } + + public async del(userId: string, keys: string[]): Promise { + 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; diff --git a/app/Store/SessionStore.ts b/app/Store/SessionStore.ts index 12e43c6..6510075 100644 --- a/app/Store/SessionStore.ts +++ b/app/Store/SessionStore.ts @@ -25,6 +25,20 @@ class RedisSessionStore { }; } + private async scanSessionKeys(): Promise> { + const keys = new Set(); + 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 * @@ -40,18 +54,10 @@ class RedisSessionStore { * Returns the all sessions from store */ public async getSessions(): Promise> { - const keys = new Set(); - let nextIndex = 0; - - do { - // eslint-disable-next-line no-await-in-loop - const [nextIndexAsStr, result] = await this.redisClient.scan(nextIndex, 'MATCH', '*', 'COUNT', 100); - nextIndex = parseInt(nextIndexAsStr, 10); - result.forEach(s => keys.add(s)); - } while (nextIndex !== 0); + const keys = await this.scanSessionKeys(); const commands = Array.from(keys).map(key => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [prefix, ...id] = key.split('-'); + const [, ...id] = key.split('-'); return ['hmget', id.join('-'), 'userId', 'userName', 'connected']; }); @@ -64,11 +70,17 @@ class RedisSessionStore { /** * Returns the unique sessions with connected flag `true` */ - public async getOnlineSessionsCount() { + public async getOnlineSessions() { const sessions = await this.getSessions(); - const online = new Set(sessions.filter(s => s?.connected === true).map(s => s?.userId)); + const online = new Map(); - return online.size; + sessions.forEach(s => { + if (s?.connected === true) { + online.set(s.userId, s); + } + }); + + return Array.from(online); } /** @@ -103,6 +115,27 @@ class RedisSessionStore { 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; diff --git a/app/Validators/Contacts/UpdateContactsValidator.ts b/app/Validators/Contacts/UpdateContactsValidator.ts index 5f98644..d8bef23 100644 --- a/app/Validators/Contacts/UpdateContactsValidator.ts +++ b/app/Validators/Contacts/UpdateContactsValidator.ts @@ -12,12 +12,24 @@ export default class UpdateContactsValidator { }), rules.unique({ table: 'contacts', column: 'phone_number' }), ]), - vk_id: schema.string.nullableAndOptional({}, [rules.regex(/^([a-zA-Z0-9_]){1,64}$/)]), - twitter_id: schema.string.nullableAndOptional({}, [rules.regex(/(^|[^@\w])@(\w{1,15})\b/)]), - telegram_id: schema.string.nullableAndOptional({}, [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.', }; } diff --git a/app/Validators/User/UpdateUserInfoValidator.ts b/app/Validators/User/UpdateUserInfoValidator.ts new file mode 100644 index 0000000..762ad17 --- /dev/null +++ b/app/Validators/User/UpdateUserInfoValidator.ts @@ -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({ locales: ['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.', + }; +} diff --git a/app/Validators/User/UpdateUserValidator.ts b/app/Validators/User/UpdateUserValidator.ts index ca9d91f..5d60734 100644 --- a/app/Validators/User/UpdateUserValidator.ts +++ b/app/Validators/User/UpdateUserValidator.ts @@ -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(), diff --git a/config/cloudinary.ts b/config/cloudinary.ts deleted file mode 100644 index 17782fd..0000000 --- a/config/cloudinary.ts +++ /dev/null @@ -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; diff --git a/config/redis.ts b/config/redis.ts index a19f00e..805b5bc 100644 --- a/config/redis.ts +++ b/config/redis.ts @@ -51,6 +51,22 @@ const redisConfig: RedisConfig = { 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, + }, }, }; diff --git a/contracts/redis.ts b/contracts/redis.ts index 55f2fec..c124ecc 100644 --- a/contracts/redis.ts +++ b/contracts/redis.ts @@ -9,5 +9,7 @@ declare module '@ioc:Adonis/Addons/Redis' { interface RedisConnectionsList { local: RedisConnectionConfig; session: RedisConnectionConfig; + message: RedisConnectionConfig; + notification: RedisConnectionConfig; } } diff --git a/database/migrations/1626545059989_users.ts b/database/migrations/1626545059989_users.ts index bda7219..8ec846e 100644 --- a/database/migrations/1626545059989_users.ts +++ b/database/migrations/1626545059989_users.ts @@ -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 }); }); diff --git a/package-lock.json b/package-lock.json index 86a0dc0..df2a993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -937,13 +937,13 @@ } }, "@adonisjs/redis": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/@adonisjs/redis/-/redis-7.0.9.tgz", - "integrity": "sha512-d+NDJuC3BG2p5YhH1vMqLXzgWDaaosk2YTklmCyvZIT0LAueXNVCyJrxduAdRA2EH/81Y5MyRhubEBnp/wQl4w==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@adonisjs/redis/-/redis-7.1.0.tgz", + "integrity": "sha512-jbe6gYMWmONVKUxQRM4Cu3851YofK91Tr1nzT1P924i/8Nb4btW4tYoU9qmjRaFfjfybQPordmEcFXXSiABDhw==", "requires": { - "@poppinss/utils": "^3.2.0", - "@types/ioredis": "^4.27.1", - "ioredis": "^4.27.8" + "@poppinss/utils": "^3.3.1", + "@types/ioredis": "^4.28.3", + "ioredis": "^4.28.2" }, "dependencies": { "@poppinss/utils": { @@ -971,9 +971,9 @@ } }, "slugify": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.1.tgz", - "integrity": "sha512-5ofqMTbetNhxlzjYYLBaZFQd6oiTuSkQlyfPEFIMwgUABlZQ0hbk5xIV9Ydd5jghWeRoO7GkiJliUvTpLOjNRA==" + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.5.tgz", + "integrity": "sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ==" } } }, @@ -2503,9 +2503,9 @@ "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, "@types/ioredis": { - "version": "4.27.8", - "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.27.8.tgz", - "integrity": "sha512-THsyghYuFI6h/UzwEaeSeagRxiDG1P/NIiL5uTjN7bcbQHwDP6nMWJfEmY0iuu3pOzl1j0FgRpAYz7WWX2eW0Q==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.8.tgz", + "integrity": "sha512-mULOyO2smtvkE1zmzRRA4P0+1UjEqusi014kXOL1q3CY0RgqkR5/wKvv+vAJbPw2Q66wPyylKeevUy+m/FaRMg==", "requires": { "@types/node": "*" } @@ -5525,9 +5525,9 @@ "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==" }, "ioredis": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.0.tgz", - "integrity": "sha512-I+zkeeWp3XFgPT2CtJKxvaF5FjGBGt4yGYljRjQecdQKteThuAsKqffeF1lgHVlYnuNeozRbPOCDNZ7tDWPeig==", + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", + "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", "requires": { "cluster-key-slot": "^1.1.0", "debug": "^4.3.1", diff --git a/package.json b/package.json index 878c3be..28ce582 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "1.1.0", + "version": "1.2.1", "private": true, "scripts": { "build": "node ace build --production", @@ -40,7 +40,7 @@ "@adonisjs/drive-s3": "^1.0.5", "@adonisjs/lucid": "^16.3.2", "@adonisjs/mail": "^7.2.4", - "@adonisjs/redis": "^7.0.9", + "@adonisjs/redis": "^7.1.0", "@adonisjs/repl": "^3.1.7", "@adonisjs/view": "^6.1.1", "@types/socket.io": "^3.0.2", diff --git a/routes/api.ts b/routes/api.ts deleted file mode 100644 index e78f64a..0000000 --- a/routes/api.ts +++ /dev/null @@ -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'); diff --git a/routes/apis/v1/index.ts b/routes/apis/v1/index.ts deleted file mode 100644 index a967e66..0000000 --- a/routes/apis/v1/index.ts +++ /dev/null @@ -1,74 +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 - * - * This module is not necessary yet - */ - // require('./images'); - - /** - * - * Categories module - * - */ - require('./categories'); - - /** - * Stat module - */ - require('./stat'); - }).middleware('auth'); -}) - .prefix('v1') - .as('v1'); diff --git a/routes/index.ts b/routes/index.ts deleted file mode 100644 index cfc24ed..0000000 --- a/routes/index.ts +++ /dev/null @@ -1,34 +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'); - - /** - * Other routes - */ - require('./videos'); -}); diff --git a/start/routes.ts b/start/routes.ts new file mode 100644 index 0000000..2149622 --- /dev/null +++ b/start/routes.ts @@ -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'; diff --git a/routes/apis/v1/auth.ts b/start/routes/apis/v1/auth.ts similarity index 89% rename from routes/apis/v1/auth.ts rename to start/routes/apis/v1/auth.ts index 90d8ee8..3fb4fba 100644 --- a/routes/apis/v1/auth.ts +++ b/start/routes/apis/v1/auth.ts @@ -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('api/v1/auth'); diff --git a/routes/apis/v1/categories.ts b/start/routes/apis/v1/categories.ts similarity index 91% rename from routes/apis/v1/categories.ts rename to start/routes/apis/v1/categories.ts index 7e021fc..759dbff 100644 --- a/routes/apis/v1/categories.ts +++ b/start/routes/apis/v1/categories.ts @@ -6,4 +6,6 @@ Route.group(() => { Route.post('/', 'Api/v1/CategoriesController.create').middleware('role:admin,teacher').as('categories.create'); Route.delete('/:id', 'Api/v1/CategoriesController.delete').middleware('role:admin,teacher').as('categories.delete'); Route.patch('/:id', 'Api/v1/CategoriesController.update').middleware('role:admin,teacher').as('categories.update'); -}).prefix('categories'); +}) + .prefix('api/v1/categories') + .middleware('auth'); diff --git a/start/routes/apis/v1/chat.ts b/start/routes/apis/v1/chat.ts new file mode 100644 index 0000000..e5de68d --- /dev/null +++ b/start/routes/apis/v1/chat.ts @@ -0,0 +1,9 @@ +import Route from '@ioc:Adonis/Core/Route'; + +Route.group(() => { + Route.get('/conversations', 'Api/v1/ChatController.getConversations') + .middleware('role:admin,teacher,student') + .as('chat.get-conversations'); +}) + .prefix('api/v1/chat') + .middleware('auth'); diff --git a/routes/apis/v1/courses.ts b/start/routes/apis/v1/courses.ts similarity index 98% rename from routes/apis/v1/courses.ts rename to start/routes/apis/v1/courses.ts index 087a483..25d5708 100644 --- a/routes/apis/v1/courses.ts +++ b/start/routes/apis/v1/courses.ts @@ -61,4 +61,6 @@ Route.group(() => { Route.get('/:id/lessons-progress', 'Api/v1/CoursesController.getLessonsProgress') .middleware('role:admin,teacher,student') .as('courses.get-lessons-progress'); -}).prefix('courses'); +}) + .prefix('api/v1/courses') + .middleware('auth'); diff --git a/routes/apis/v1/health.ts b/start/routes/apis/v1/health.ts similarity index 88% rename from routes/apis/v1/health.ts rename to start/routes/apis/v1/health.ts index b3ba089..0090e9f 100644 --- a/routes/apis/v1/health.ts +++ b/start/routes/apis/v1/health.ts @@ -8,4 +8,6 @@ Route.group(() => { return report.healthy ? ctx.response.ok(report) : ctx.response.badRequest(report); }).as('health'); -}).prefix('health'); +}) + .prefix('api/v1/health') + .middleware('auth'); diff --git a/routes/apis/v1/images.ts b/start/routes/apis/v1/images.ts similarity index 87% rename from routes/apis/v1/images.ts rename to start/routes/apis/v1/images.ts index bae3929..fbbf7de 100644 --- a/routes/apis/v1/images.ts +++ b/start/routes/apis/v1/images.ts @@ -5,4 +5,6 @@ Route.group(() => { Route.delete('/:id', 'Api/v1/ImagesController.delete').as('images.delete'); Route.get('/', 'Api/v1/ImagesController.list').as('images.list'); Route.get('/:id', 'Api/v1/ImagesController.show').as('images.show'); -}).prefix('images'); +}) + .prefix('api/v1/images') + .middleware('auth'); diff --git a/routes/apis/v1/lessons.ts b/start/routes/apis/v1/lessons.ts similarity index 95% rename from routes/apis/v1/lessons.ts rename to start/routes/apis/v1/lessons.ts index 7893120..ee261f4 100644 --- a/routes/apis/v1/lessons.ts +++ b/start/routes/apis/v1/lessons.ts @@ -18,4 +18,6 @@ Route.group(() => { Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress') .middleware('role:admin,teacher,student') .as('lessons.get-video-progress'); -}).prefix('lessons'); +}) + .prefix('api/v1/lessons') + .middleware('auth'); diff --git a/routes/apis/v1/me.ts b/start/routes/apis/v1/me.ts similarity index 70% rename from routes/apis/v1/me.ts rename to start/routes/apis/v1/me.ts index 840c6d8..d7b847c 100644 --- a/routes/apis/v1/me.ts +++ b/start/routes/apis/v1/me.ts @@ -2,6 +2,10 @@ import Route from '@ioc:Adonis/Core/Route'; Route.group(() => { Route.get('/', 'Api/v1/MeController.show').middleware('role:admin,teacher,student').as('me.show'); + Route.get('/messages/:chat_id', 'Api/v1/MeController.chatHistory') + .middleware('role:admin,teacher,student') + .as('me.chat-history'); + Route.patch('/info', 'Api/v1/MeController.updateInfo').middleware('role:admin,teacher,student').as('me.update-info'); Route.patch('/email', 'Api/v1/MeController.changeEmail') .middleware('role:admin,teacher,student') .as('me.change-email'); @@ -14,4 +18,6 @@ Route.group(() => { Route.patch('/contacts', 'Api/v1/MeController.updateContacts') .middleware('role:admin,teacher,student') .as('me.update-contacts'); -}).prefix('me'); +}) + .prefix('api/v1/me') + .middleware('auth'); diff --git a/routes/apis/v1/stat.ts b/start/routes/apis/v1/stat.ts similarity index 77% rename from routes/apis/v1/stat.ts rename to start/routes/apis/v1/stat.ts index 109a29e..9c17d3c 100644 --- a/routes/apis/v1/stat.ts +++ b/start/routes/apis/v1/stat.ts @@ -2,4 +2,6 @@ import Route from '@ioc:Adonis/Core/Route'; Route.group(() => { Route.get('/', 'Api/v1/StatController.index').middleware('role:admin,teacher,student').as('stat.index'); -}).prefix('stat'); +}) + .prefix('api/v1/stat') + .middleware('auth'); diff --git a/routes/apis/v1/users.ts b/start/routes/apis/v1/users.ts similarity index 94% rename from routes/apis/v1/users.ts rename to start/routes/apis/v1/users.ts index 41b392e..e19a453 100644 --- a/routes/apis/v1/users.ts +++ b/start/routes/apis/v1/users.ts @@ -12,4 +12,6 @@ Route.group(() => { Route.delete('/:id/detach-roles', 'Api/v1/UsersController.detachRoles') .middleware('role:admin') .as('users.detach-role'); -}).prefix('users'); +}) + .prefix('api/v1/users') + .middleware('auth'); diff --git a/start/routes/index.ts b/start/routes/index.ts new file mode 100644 index 0000000..b4523b0 --- /dev/null +++ b/start/routes/index.ts @@ -0,0 +1,3 @@ +import Route from '@ioc:Adonis/Core/Route'; + +Route.get('/', () => 'Educt Backend API'); diff --git a/routes/videos.ts b/start/routes/videos.ts similarity index 100% rename from routes/videos.ts rename to start/routes/videos.ts