diff --git a/app/Controllers/Http/Api/v1/MeController.ts b/app/Controllers/Http/Api/v1/MeController.ts index 21ce2c9..2216cf4 100644 --- a/app/Controllers/Http/Api/v1/MeController.ts +++ b/app/Controllers/Http/Api/v1/MeController.ts @@ -124,6 +124,20 @@ export default class MeController extends BaseController { 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/Services/MeService.ts b/app/Services/MeService.ts index ebd7875..15c74f4 100644 --- a/app/Services/MeService.ts +++ b/app/Services/MeService.ts @@ -5,15 +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'; -import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator'; -import CodeErrorEnum from 'App/Datatypes/Enums/CodeErrorEnum'; -import RoleHelper from 'App/Helpers/RoleHelper'; -import RoleEnum from 'App/Datatypes/Enums/RoleEnum'; + +/** + * Validator + */ import UpdateUserInfoValidator from 'App/Validators/User/UpdateUserInfoValidator'; +import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator'; + +/** + * Helpers + */ +import RoleHelper from 'App/Helpers/RoleHelper'; + +/** + * Services + */ +import Ws from './WsService'; @inject() export default class MeService { @@ -336,6 +357,41 @@ export default class MeService { 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/WsService.ts b/app/Services/WsService.ts index 781def5..731016f 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,8 +12,12 @@ import OnlineController from 'App/Controllers/Ws/OnlineController'; * Middlewares */ import AuthMiddleware from 'App/Middleware/Ws/Auth'; + +/** + * Stores + */ import RedisSessionStore from 'App/Store/SessionStore'; -import { DateTime } from 'luxon'; +import RedisMessageStore from 'App/Store/MessageStore'; export interface ServerToClientEvents { /** @@ -54,6 +59,8 @@ class WsService { public sessionStore: RedisSessionStore; + public messageStore: RedisMessageStore; + public booted = false; public boot() { @@ -68,7 +75,12 @@ class WsService { credentials: true, }, }); + + /** + * Stores + */ this.sessionStore = new RedisSessionStore(Redis.connection('session')); + this.messageStore = new RedisMessageStore(Redis.connection('message')); /** * Clean up sessions before start @@ -103,9 +115,17 @@ class WsService { */ socket.emit('user:session', { sessionId: socket.data.sessionId, userId: socket.data.userId }); - socket.on('chat:message', ({ content, to }) => { + socket.on('chat:message', async ({ content, to }) => { if (isExistSocketData) { - this.io.to(to).emit('chat:message', { content, from: userId, to, time: DateTime.now().toISO() }); + const message = { + content, + from: userId, + to, + time: DateTime.now().toISO(), + }; + + await this.messageStore.add(userId, to, message); + this.io.to(to).emit('chat:message', message); } }); diff --git a/app/Store/MessageStore.ts b/app/Store/MessageStore.ts new file mode 100644 index 0000000..f289bdb --- /dev/null +++ b/app/Store/MessageStore.ts @@ -0,0 +1,69 @@ +import { RedisConnectionContract } from '@ioc:Adonis/Addons/Redis'; +import Logger from '@ioc:Adonis/Core/Logger'; +import { DateTime } from 'luxon'; +import { nanoid } from 'nanoid'; + +type MessageDataType = { + from: string; + to: string; + content: string; + time: string; +}; + +const MESSAGE_TTL = 185 * 24 * 60 * 60; // 185 days + +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); + await this.redisClient.lpush(convKey, msgKey); + + 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()); + } + + 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/config/redis.ts b/config/redis.ts index a19f00e..075fa67 100644 --- a/config/redis.ts +++ b/config/redis.ts @@ -51,6 +51,14 @@ 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, + }, }, }; diff --git a/contracts/redis.ts b/contracts/redis.ts index 55f2fec..698585a 100644 --- a/contracts/redis.ts +++ b/contracts/redis.ts @@ -9,5 +9,6 @@ declare module '@ioc:Adonis/Addons/Redis' { interface RedisConnectionsList { local: RedisConnectionConfig; session: RedisConnectionConfig; + message: RedisConnectionConfig; } } 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..c1c19b0 100644 --- a/package.json +++ b/package.json @@ -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/apis/v1/me.ts b/routes/apis/v1/me.ts index 38e79e3..c386537 100644 --- a/routes/apis/v1/me.ts +++ b/routes/apis/v1/me.ts @@ -2,6 +2,9 @@ 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')