Merge branch 'release/1.2.1'

This commit is contained in:
Sergey Yarkov 2022-03-06 23:05:11 +03:00
commit c7607c6eae
40 changed files with 729 additions and 204 deletions

View File

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

View File

@ -4,6 +4,22 @@
- API deployment with Docker and Nginx web server. - 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 ## [1.1.0] - 2022-01-31
### Added ### Added

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

@ -12,6 +12,7 @@ import MeService from 'App/Services/MeService';
*/ */
import ChangePasswordValidator from 'App/Validators/Password/ChangePasswordValidator'; import ChangePasswordValidator from 'App/Validators/Password/ChangePasswordValidator';
import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator'; import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator';
import UpdateUserInfoValidator from 'App/Validators/User/UpdateUserInfoValidator';
import BaseController from '../../BaseController'; import BaseController from '../../BaseController';
@ -108,6 +109,35 @@ export default class MeController extends BaseController {
return this.sendResponse(ctx, result.data, result.message, result.status); 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); new Ioc().make(MeController);

View File

@ -23,9 +23,8 @@ export default class OnlineController {
this.socket.on('disconnect', reason => this.onDisconnected(reason)); this.socket.on('disconnect', reason => this.onDisconnected(reason));
} }
public sendOnlineCount(online: number) { public sendOnline(online: [string, { userId: string; userName: string }][]) {
this.socket.emit('user:online', online); this.io.sockets.emit('user:online', online);
this.socket.broadcast.emit('user:online', online);
} }
public setUserOnline() { public setUserOnline() {
@ -37,8 +36,8 @@ export default class OnlineController {
*/ */
this.sessionStore this.sessionStore
.saveSession(sessionId, { userId, userName, connected: true }) .saveSession(sessionId, { userId, userName, connected: true })
.then(() => this.sessionStore.getOnlineSessionsCount()) .then(() => this.sessionStore.getOnlineSessions())
.then(online => this.sendOnlineCount(online)); .then(online => this.sendOnline(online));
} }
} }
@ -54,7 +53,7 @@ export default class OnlineController {
*/ */
this.sessionStore this.sessionStore
.saveSession(sessionId, { userId, userName, connected: false }) .saveSession(sessionId, { userId, userName, connected: false })
.then(() => this.sessionStore.getOnlineSessionsCount()) .then(() => this.sessionStore.getOnlineSessions())
.then(online => this.socket.broadcast.emit('user:online', online)); .then(online => this.socket.broadcast.emit('user:online', online));
} }
} }

View File

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

View File

@ -70,6 +70,12 @@ export default class User extends BaseModel {
}) })
public lessons_progress: HasMany<typeof LessonProgress>; public lessons_progress: HasMany<typeof LessonProgress>;
@column()
public about: string | null;
@column.dateTime()
public last_login: DateTime | null;
@column.dateTime({ autoCreate: true }) @column.dateTime({ autoCreate: true })
public createdAt: DateTime; public createdAt: DateTime;

View File

@ -1,4 +1,5 @@
import Hash from '@ioc:Adonis/Core/Hash'; import Hash from '@ioc:Adonis/Core/Hash';
import { DateTime } from 'luxon';
/** /**
* Models * Models
@ -11,6 +12,7 @@ import Role from 'App/Models/Role';
*/ */
import CreateUserValidator from 'App/Validators/User/CreateUserValidator'; import CreateUserValidator from 'App/Validators/User/CreateUserValidator';
import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator'; import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator';
import UpdateUserInfoValidator from 'App/Validators/User/UpdateUserInfoValidator';
export default class UserRepository { export default class UserRepository {
private User: typeof User; private User: typeof User;
@ -159,6 +161,32 @@ export default class UserRepository {
return null; 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 * Rewrite all user roles
* *

View File

@ -66,12 +66,21 @@ export default class AuthService {
*/ */
const token = await ctx.auth.use(this.authGuard).generate(user, { const token = await ctx.auth.use(this.authGuard).generate(user, {
expiresIn: '1d', expiresIn: '1d',
userId: user.id,
userRoles: user.roles.map(role => role.slug), userRoles: user.roles.map(role => role.slug),
userName: user.fullname, userName: user.fullname,
}); });
/**
* Set cookie toke to response
*/
ctx.response.cookie('token', token.token); ctx.response.cookie('token', token.token);
/**
* Save last login date
*/
await this.userRepository.updateLastLogin(user.id);
return { return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,

View File

@ -5,14 +5,36 @@ import Redis from '@ioc:Adonis/Addons/Redis';
import Mail from '@ioc:Adonis/Addons/Mail'; import Mail from '@ioc:Adonis/Addons/Mail';
import Hash from '@ioc:Adonis/Core/Hash'; import Hash from '@ioc:Adonis/Core/Hash';
import Logger from '@ioc:Adonis/Core/Logger'; import Logger from '@ioc:Adonis/Core/Logger';
/**
* Datatypes
*/
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum'; import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
import IResponse from 'App/Datatypes/Interfaces/IResponse'; import IResponse from 'App/Datatypes/Interfaces/IResponse';
import CodeErrorEnum from 'App/Datatypes/Enums/CodeErrorEnum';
/**
* Repositories
*/
import ContactRepository from 'App/Repositories/ContactRepository'; import ContactRepository from 'App/Repositories/ContactRepository';
import UserRepository from 'App/Repositories/UserRepository'; import UserRepository from 'App/Repositories/UserRepository';
/**
* Validator
*/
import UpdateUserInfoValidator from 'App/Validators/User/UpdateUserInfoValidator';
import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator'; import UpdateContactsValidator from 'App/Validators/Contacts/UpdateContactsValidator';
import CodeErrorEnum from 'App/Datatypes/Enums/CodeErrorEnum';
/**
* Helpers
*/
import RoleHelper from 'App/Helpers/RoleHelper'; import RoleHelper from 'App/Helpers/RoleHelper';
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
/**
* Services
*/
import Ws from './WsService';
@inject() @inject()
export default class MeService { export default class MeService {
@ -55,11 +77,16 @@ export default class MeService {
}); });
} }
const notifications = await Ws.notificationStore.getNotifications(user.id);
return { return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,
message: 'Fetched authorized user data.', message: 'Fetched authorized user data.',
data: user, data: {
...user.toJSON(),
notifications,
},
}; };
} }
@ -247,9 +274,9 @@ export default class MeService {
/** /**
* Update user conctact * Update user conctact
* *
* @param data Data to update * @param data Fields to update
* @param auth AuthContract * @param auth AuthContract
* @returns Updated user contacts * @returns IResponse
*/ */
public async updateUserContacts( public async updateUserContacts(
data: UpdateContactsValidator['schema']['props'], data: UpdateContactsValidator['schema']['props'],
@ -279,6 +306,93 @@ export default class MeService {
data: user.contacts, 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); new Ioc().make(MeService);

View File

@ -20,7 +20,7 @@ export default class StatService {
` `
); );
const online = await Ws.sessionStore.getOnlineSessionsCount(); const online = await Ws.sessionStore.getOnlineSessions();
return { return {
success: true, success: true,
@ -28,7 +28,7 @@ export default class StatService {
message: 'Fetched stat.', message: 'Fetched stat.',
data: { data: {
...count.rows[0], ...count.rows[0],
online, online: online.length,
}, },
}; };
} }

View File

@ -1,6 +1,7 @@
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import Redis from '@ioc:Adonis/Addons/Redis'; import Redis from '@ioc:Adonis/Addons/Redis';
import AdonisServer from '@ioc:Adonis/Core/Server'; import AdonisServer from '@ioc:Adonis/Core/Server';
import { DateTime } from 'luxon';
/** /**
* Controllers * Controllers
@ -11,7 +12,13 @@ import OnlineController from 'App/Controllers/Ws/OnlineController';
* Middlewares * Middlewares
*/ */
import AuthMiddleware from 'App/Middleware/Ws/Auth'; import AuthMiddleware from 'App/Middleware/Ws/Auth';
/**
* Stores
*/
import RedisSessionStore from 'App/Store/SessionStore'; import RedisSessionStore from 'App/Store/SessionStore';
import RedisMessageStore from 'App/Store/MessageStore';
import RedisNotificationStore from 'App/Store/NotificationStore';
export interface ServerToClientEvents { export interface ServerToClientEvents {
/** /**
@ -19,7 +26,18 @@ export interface ServerToClientEvents {
*/ */
'user:session': (data: { sessionId: string | undefined; userId: string | undefined }) => void; 'user:session': (data: { sessionId: string | undefined; userId: string | undefined }) => void;
'user:connected': (data: { userId: string; userName: string }) => 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 { export interface ClientToServerEvents {
@ -28,6 +46,16 @@ export interface ClientToServerEvents {
*/ */
'user:logout': () => void; 'user:logout': () => void;
'user:status': () => 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 InterServerEvents {}
@ -43,6 +71,10 @@ class WsService {
public sessionStore: RedisSessionStore; public sessionStore: RedisSessionStore;
public messageStore: RedisMessageStore;
public notificationStore: RedisNotificationStore;
public booted = false; public booted = false;
public boot() { public boot() {
@ -57,25 +89,69 @@ class WsService {
credentials: true, credentials: true,
}, },
}); });
this.sessionStore = new RedisSessionStore(Redis.connection('session'));
/**
* 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(); this.setupMiddlewares();
/**
* On connection socket
*/
this.listen(); this.listen();
});
} }
private listen() { private listen() {
this.io.on('connection', socket => { this.io.on('connection', async socket => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const onlineController = new OnlineController(socket, this.io, this.sessionStore); const onlineController = new OnlineController(socket, this.io, this.sessionStore);
const { sessionId, userId, userName } = socket.data; const { sessionId, userId, userName } = socket.data;
const isExistSocketData = !!(sessionId && userId && userName); const isExistSocketData = !!(sessionId && userId && userName);
if (isExistSocketData) {
socket.join(userId);
}
/** /**
* Send session to client * Send session to client
*/ */
socket.emit('user:session', { sessionId: socket.data.sessionId, userId: socket.data.userId }); 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 * Destroy session on user logout request
*/ */

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;

View File

@ -25,6 +25,20 @@ class RedisSessionStore {
}; };
} }
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 * Return session data by session id
* *
@ -40,18 +54,10 @@ class RedisSessionStore {
* Returns the all sessions from store * Returns the all sessions from store
*/ */
public async getSessions(): Promise<Array<SessionDataType | undefined>> { public async getSessions(): Promise<Array<SessionDataType | undefined>> {
const keys = new Set<string>(); const keys = await this.scanSessionKeys();
let nextIndex = 0;
do {
// eslint-disable-next-line no-await-in-loop
const [nextIndexAsStr, result] = await this.redisClient.scan(nextIndex, 'MATCH', '*', 'COUNT', 100);
nextIndex = parseInt(nextIndexAsStr, 10);
result.forEach(s => keys.add(s));
} while (nextIndex !== 0);
const commands = Array.from(keys).map(key => { const commands = Array.from(keys).map(key => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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']; return ['hmget', id.join('-'), 'userId', 'userName', 'connected'];
}); });
@ -64,11 +70,17 @@ class RedisSessionStore {
/** /**
* Returns the unique sessions with connected flag `true` * Returns the unique sessions with connected flag `true`
*/ */
public async getOnlineSessionsCount() { public async getOnlineSessions() {
const sessions = await this.getSessions(); const sessions = await this.getSessions();
const online = new Set(sessions.filter(s => s?.connected === true).map(s => s?.userId)); const online = new Map<string, { userId: string; userName: string }>();
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; 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; export default RedisSessionStore;

View File

@ -12,12 +12,24 @@ export default class UpdateContactsValidator {
}), }),
rules.unique({ table: 'contacts', column: 'phone_number' }), rules.unique({ table: 'contacts', column: 'phone_number' }),
]), ]),
vk_id: schema.string.nullableAndOptional({}, [rules.regex(/^([a-zA-Z0-9_]){1,64}$/)]), vk_id: schema.string.nullableAndOptional({}, [
twitter_id: schema.string.nullableAndOptional({}, [rules.regex(/(^|[^@\w])@(\w{1,15})\b/)]), rules.regex(/^([a-zA-Z0-9_]){1,64}$/),
telegram_id: schema.string.nullableAndOptional({}, [rules.regex(/(^|[^@\w])@(\w{1,64})\b/)]), 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 = { 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,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.',
};
}

View File

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

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

@ -51,6 +51,22 @@ const redisConfig: RedisConfig = {
keyPrefix: 'session-', keyPrefix: 'session-',
healthCheck: true, 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

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

View File

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

32
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "1.0.1", "version": "1.1.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -937,13 +937,13 @@
} }
}, },
"@adonisjs/redis": { "@adonisjs/redis": {
"version": "7.0.9", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@adonisjs/redis/-/redis-7.0.9.tgz", "resolved": "https://registry.npmjs.org/@adonisjs/redis/-/redis-7.1.0.tgz",
"integrity": "sha512-d+NDJuC3BG2p5YhH1vMqLXzgWDaaosk2YTklmCyvZIT0LAueXNVCyJrxduAdRA2EH/81Y5MyRhubEBnp/wQl4w==", "integrity": "sha512-jbe6gYMWmONVKUxQRM4Cu3851YofK91Tr1nzT1P924i/8Nb4btW4tYoU9qmjRaFfjfybQPordmEcFXXSiABDhw==",
"requires": { "requires": {
"@poppinss/utils": "^3.2.0", "@poppinss/utils": "^3.3.1",
"@types/ioredis": "^4.27.1", "@types/ioredis": "^4.28.3",
"ioredis": "^4.27.8" "ioredis": "^4.28.2"
}, },
"dependencies": { "dependencies": {
"@poppinss/utils": { "@poppinss/utils": {
@ -971,9 +971,9 @@
} }
}, },
"slugify": { "slugify": {
"version": "1.6.1", "version": "1.6.5",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.1.tgz", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.5.tgz",
"integrity": "sha512-5ofqMTbetNhxlzjYYLBaZFQd6oiTuSkQlyfPEFIMwgUABlZQ0hbk5xIV9Ydd5jghWeRoO7GkiJliUvTpLOjNRA==" "integrity": "sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ=="
} }
} }
}, },
@ -2503,9 +2503,9 @@
"integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
}, },
"@types/ioredis": { "@types/ioredis": {
"version": "4.27.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.27.8.tgz", "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.8.tgz",
"integrity": "sha512-THsyghYuFI6h/UzwEaeSeagRxiDG1P/NIiL5uTjN7bcbQHwDP6nMWJfEmY0iuu3pOzl1j0FgRpAYz7WWX2eW0Q==", "integrity": "sha512-mULOyO2smtvkE1zmzRRA4P0+1UjEqusi014kXOL1q3CY0RgqkR5/wKvv+vAJbPw2Q66wPyylKeevUy+m/FaRMg==",
"requires": { "requires": {
"@types/node": "*" "@types/node": "*"
} }
@ -5525,9 +5525,9 @@
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==" "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw=="
}, },
"ioredis": { "ioredis": {
"version": "4.28.0", "version": "4.28.5",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.0.tgz", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz",
"integrity": "sha512-I+zkeeWp3XFgPT2CtJKxvaF5FjGBGt4yGYljRjQecdQKteThuAsKqffeF1lgHVlYnuNeozRbPOCDNZ7tDWPeig==", "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==",
"requires": { "requires": {
"cluster-key-slot": "^1.1.0", "cluster-key-slot": "^1.1.0",
"debug": "^4.3.1", "debug": "^4.3.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "1.1.0", "version": "1.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node ace build --production", "build": "node ace build --production",
@ -40,7 +40,7 @@
"@adonisjs/drive-s3": "^1.0.5", "@adonisjs/drive-s3": "^1.0.5",
"@adonisjs/lucid": "^16.3.2", "@adonisjs/lucid": "^16.3.2",
"@adonisjs/mail": "^7.2.4", "@adonisjs/mail": "^7.2.4",
"@adonisjs/redis": "^7.0.9", "@adonisjs/redis": "^7.1.0",
"@adonisjs/repl": "^3.1.7", "@adonisjs/repl": "^3.1.7",
"@adonisjs/view": "^6.1.1", "@adonisjs/view": "^6.1.1",
"@types/socket.io": "^3.0.2", "@types/socket.io": "^3.0.2",

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,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');

View File

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

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.group(() => {
Route.post('login', 'Api/v1/AuthController.login').as('auth.login'); Route.post('login', 'Api/v1/AuthController.login').as('auth.login');
Route.post('logout', 'Api/v1/AuthController.logout').middleware('auth').as('auth.logout'); Route.post('logout', 'Api/v1/AuthController.logout').middleware('auth').as('auth.logout');
}).prefix('auth'); }).prefix('api/v1/auth');

View File

@ -6,4 +6,6 @@ Route.group(() => {
Route.post('/', 'Api/v1/CategoriesController.create').middleware('role:admin,teacher').as('categories.create'); Route.post('/', 'Api/v1/CategoriesController.create').middleware('role:admin,teacher').as('categories.create');
Route.delete('/:id', 'Api/v1/CategoriesController.delete').middleware('role:admin,teacher').as('categories.delete'); Route.delete('/:id', 'Api/v1/CategoriesController.delete').middleware('role:admin,teacher').as('categories.delete');
Route.patch('/:id', 'Api/v1/CategoriesController.update').middleware('role:admin,teacher').as('categories.update'); Route.patch('/:id', 'Api/v1/CategoriesController.update').middleware('role:admin,teacher').as('categories.update');
}).prefix('categories'); })
.prefix('api/v1/categories')
.middleware('auth');

View File

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

View File

@ -61,4 +61,6 @@ Route.group(() => {
Route.get('/:id/lessons-progress', 'Api/v1/CoursesController.getLessonsProgress') Route.get('/:id/lessons-progress', 'Api/v1/CoursesController.getLessonsProgress')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('courses.get-lessons-progress'); .as('courses.get-lessons-progress');
}).prefix('courses'); })
.prefix('api/v1/courses')
.middleware('auth');

View File

@ -8,4 +8,6 @@ Route.group(() => {
return report.healthy ? ctx.response.ok(report) : ctx.response.badRequest(report); return report.healthy ? ctx.response.ok(report) : ctx.response.badRequest(report);
}).as('health'); }).as('health');
}).prefix('health'); })
.prefix('api/v1/health')
.middleware('auth');

View File

@ -5,4 +5,6 @@ Route.group(() => {
Route.delete('/:id', 'Api/v1/ImagesController.delete').as('images.delete'); Route.delete('/:id', 'Api/v1/ImagesController.delete').as('images.delete');
Route.get('/', 'Api/v1/ImagesController.list').as('images.list'); Route.get('/', 'Api/v1/ImagesController.list').as('images.list');
Route.get('/:id', 'Api/v1/ImagesController.show').as('images.show'); Route.get('/:id', 'Api/v1/ImagesController.show').as('images.show');
}).prefix('images'); })
.prefix('api/v1/images')
.middleware('auth');

View File

@ -18,4 +18,6 @@ Route.group(() => {
Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress') Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('lessons.get-video-progress'); .as('lessons.get-video-progress');
}).prefix('lessons'); })
.prefix('api/v1/lessons')
.middleware('auth');

View File

@ -2,6 +2,10 @@ import Route from '@ioc:Adonis/Core/Route';
Route.group(() => { Route.group(() => {
Route.get('/', 'Api/v1/MeController.show').middleware('role:admin,teacher,student').as('me.show'); 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') Route.patch('/email', 'Api/v1/MeController.changeEmail')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('me.change-email'); .as('me.change-email');
@ -14,4 +18,6 @@ Route.group(() => {
Route.patch('/contacts', 'Api/v1/MeController.updateContacts') Route.patch('/contacts', 'Api/v1/MeController.updateContacts')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('me.update-contacts'); .as('me.update-contacts');
}).prefix('me'); })
.prefix('api/v1/me')
.middleware('auth');

View File

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

View File

@ -12,4 +12,6 @@ Route.group(() => {
Route.delete('/:id/detach-roles', 'Api/v1/UsersController.detachRoles') Route.delete('/:id/detach-roles', 'Api/v1/UsersController.detachRoles')
.middleware('role:admin') .middleware('role:admin')
.as('users.detach-role'); .as('users.detach-role');
}).prefix('users'); })
.prefix('api/v1/users')
.middleware('auth');

3
start/routes/index.ts Normal file
View File

@ -0,0 +1,3 @@
import Route from '@ioc:Adonis/Core/Route';
Route.get('/', () => 'Educt Backend API');