From 9bbbc928f14df59c6089f452dfae4770fdefb599 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 2 Dec 2024 19:20:19 +0200 Subject: [PATCH] refactor(backend): improve join call events logic --- .../nova/src/components/Chat/MessageGroup.tsx | 70 ------ .../components/UpcomingLessons/Content.tsx | 10 +- packages/models/src/calls.ts | 5 +- packages/models/tests/calls.test.ts | 12 +- packages/models/tsconfig.json | 4 +- packages/types/src/call.ts | 2 + packages/types/src/wss.ts | 14 +- services/server/fixtures/api.ts | 1 - services/server/src/wss/handler.ts | 221 ++++++++++-------- services/server/tests/wss/call.test.ts | 73 +++--- 10 files changed, 191 insertions(+), 221 deletions(-) delete mode 100644 apps/nova/src/components/Chat/MessageGroup.tsx diff --git a/apps/nova/src/components/Chat/MessageGroup.tsx b/apps/nova/src/components/Chat/MessageGroup.tsx deleted file mode 100644 index a4f8c96a5..000000000 --- a/apps/nova/src/components/Chat/MessageGroup.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -import cn from "classnames"; -import dayjs from "@/lib/dayjs"; -import { asFullAssetUrl } from "@litespace/luna/backend"; -import { MessageGroup as IMessageGroup } from "@litespace/luna/chat"; -import Message from "@/components/Chat/Message"; -import { IMessage } from "@litespace/types"; -import { AnimatePresence, motion } from "framer-motion"; - -const messageVariants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.3 } }, -} as const; - -const MessageGroup: React.FC<{ - group: IMessageGroup; - onUpdateMessage: (message: IMessage.Self) => void; - onDeleteMessage: (message: IMessage.Self) => void; -}> = ({ - group: { sender, messages, sentAt }, - onUpdateMessage, - onDeleteMessage, -}) => { - return ( -
  • -
    - -
    -
    -
    -

    - {sender.name} -

    -

    - —   - {dayjs(sentAt).fromNow()} -

    -
    - - - - {messages.map((message) => { - return ( - - onUpdateMessage(message)} - onDeleteMessage={() => onDeleteMessage(message)} - /> - - ); - })} - - -
    -
  • - ); -}; - -export default MessageGroup; diff --git a/apps/nova/src/components/UpcomingLessons/Content.tsx b/apps/nova/src/components/UpcomingLessons/Content.tsx index 7e8ee7ec2..3c1d083f2 100644 --- a/apps/nova/src/components/UpcomingLessons/Content.tsx +++ b/apps/nova/src/components/UpcomingLessons/Content.tsx @@ -16,11 +16,7 @@ export const Content: React.FC = ({ query }) => { const canceled = useCallback( (item: Lessons[0], tutor: ILesson.PopuldatedMember) => { if (!item.lesson.canceledBy && !item.lesson.canceledBy) return null; - if ( - item.lesson.canceledBy === tutor.userId || - item.call.canceledBy === tutor.userId - ) - return "tutor"; + if (item.lesson.canceledBy === tutor.userId) return "tutor"; return "student"; }, [] @@ -51,8 +47,8 @@ export const Content: React.FC = ({ query }) => { return ( console.log("join")} onCancel={() => console.log("canceled")} onRebook={() => console.log("rebook")} diff --git a/packages/models/src/calls.ts b/packages/models/src/calls.ts index 54a031f6c..1e21bc228 100644 --- a/packages/models/src/calls.ts +++ b/packages/models/src/calls.ts @@ -38,7 +38,10 @@ export class Calls { callId, userId, tx, - }: WithOptionalTx<{ callId: number; userId: number }>): Promise { + }: WithOptionalTx<{ + callId: number; + userId: number; + }>): Promise { const rows = await this.builder(tx) .members.insert({ call_id: callId, diff --git a/packages/models/tests/calls.test.ts b/packages/models/tests/calls.test.ts index b61a982fc..2d0b1aa76 100644 --- a/packages/models/tests/calls.test.ts +++ b/packages/models/tests/calls.test.ts @@ -27,8 +27,8 @@ describe(nameof(Calls), () => { expect(await calls.findCallMembers([call.id])).to.be.of.length(0); const member = await calls.addMember({ - call: call.id, - user: tutor.id, + callId: call.id, + userId: tutor.id, }); expect(await calls.findCallMembers([call.id])).to.be.of.length(1); expect(member.callId).to.be.eq(call.id); @@ -44,15 +44,15 @@ describe(nameof(Calls), () => { expect(await calls.findCallMembers([call.id])).to.be.of.length(0); await calls.addMember({ - call: call.id, - user: tutor.id, + callId: call.id, + userId: tutor.id, }); expect(await calls.findCallMembers([call.id])).to.be.of.length(1); await calls.removeMember({ - call: call.id, - user: tutor.id, + callId: call.id, + userId: tutor.id, }); expect(await calls.findCallMembers([call.id])).to.be.of.length(0); diff --git a/packages/models/tsconfig.json b/packages/models/tsconfig.json index 0ac7f3f42..305fbda5e 100644 --- a/packages/models/tsconfig.json +++ b/packages/models/tsconfig.json @@ -6,12 +6,12 @@ "strict": true, "baseUrl": "./", "skipLibCheck": true, - "module": "ESNext", + "module": "nodenext", "declaration": true, "declarationDir": "types", "sourceMap": true, "outDir": "dist", - "moduleResolution": "node", + "moduleResolution": "nodenext", "allowSyntheticDefaultImports": true, "emitDeclarationOnly": true, "resolveJsonModule": true, diff --git a/packages/types/src/call.ts b/packages/types/src/call.ts index 506b5d6b4..2b7ab2956 100644 --- a/packages/types/src/call.ts +++ b/packages/types/src/call.ts @@ -73,3 +73,5 @@ export type FindUserCallsApiResponse = { calls: Self[]; members: Record; }; + +export type Type = "lesson" | "interview"; diff --git a/packages/types/src/wss.ts b/packages/types/src/wss.ts index e5fac6cc4..8697bcb31 100644 --- a/packages/types/src/wss.ts +++ b/packages/types/src/wss.ts @@ -1,4 +1,4 @@ -import { IMessage, IRule, ITutor, Server } from "@/index"; +import { IMessage, IRule, ITutor, Server, ICall } from "@/index"; /** * Events emitted by the client @@ -37,6 +37,9 @@ export enum ServerEvent { MemberJoinedCall = "MemberJoinedCallId", MemberLeftCall = "MemberLeftCallId", + /** + * @deprecated + */ UserJoinedCall = "UserJoinedCall", UserSharedScreen = "UserSharedScreen", UserStatusChanged = "UserStatusChanged", @@ -81,7 +84,10 @@ export type ClientEventsMap = { [ClientEvent.ToggleCamera]: EventCallback<{ call: number; camera: boolean }>; [ClientEvent.ToggleMic]: EventCallback<{ call: number; mic: boolean }>; [ClientEvent.UserTyping]: EventCallback<{ roomId: number }>; - [ClientEvent.JoinCall]: EventCallback<{ callId: number }>; + [ClientEvent.JoinCall]: EventCallback<{ + callId: number; + type: ICall.Type; + }>; [ClientEvent.LeaveCall]: EventCallback<{ callId: number }>; }; @@ -98,8 +104,8 @@ export type ServerEventsMap = { [ServerEvent.UserJoinedCall]: EventCallback<{ peerId: string }>; - [ServerEvent.MemberJoinedCall]: EventCallback<{ memberId: number }>; - [ServerEvent.MemberLeftCall]: EventCallback<{ memberId: number }>; + [ServerEvent.MemberJoinedCall]: EventCallback<{ userId: number }>; + [ServerEvent.MemberLeftCall]: EventCallback<{ userId: number }>; [ServerEvent.CameraToggled]: EventCallback<{ user: number; camera: boolean }>; [ServerEvent.MicToggled]: EventCallback<{ user: number; mic: boolean }>; diff --git a/services/server/fixtures/api.ts b/services/server/fixtures/api.ts index 79ec7c896..fae9a6d55 100644 --- a/services/server/fixtures/api.ts +++ b/services/server/fixtures/api.ts @@ -35,7 +35,6 @@ export class Api { static async forUser(role: IUser.Role) { const email = faker.internet.email(); const password = faker.internet.password(); - // NOTE: should be db.createUser await db.user({ role, email, password }); return await Api.fromCredentials(email, password); } diff --git a/services/server/src/wss/handler.ts b/services/server/src/wss/handler.ts index 478820926..c1d76f236 100644 --- a/services/server/src/wss/handler.ts +++ b/services/server/src/wss/handler.ts @@ -9,13 +9,7 @@ import { logger } from "@litespace/sol/log"; import { safe } from "@litespace/sol/error"; import { sanitizeMessage } from "@litespace/sol/chat"; import "colors"; -import { - isAdmin, - isGhost, - isStudent, - isTutor, - isUser, -} from "@litespace/auth"; +import { isAdmin, isGhost, isStudent, isTutor, isUser } from "@litespace/auth"; import { background } from "@/workers"; import { PartentPortMessage, PartentPortMessageType } from "@/workers/messages"; import { cache } from "@/lib/cache"; @@ -67,81 +61,96 @@ export class WssHandler { } /* - * This event listener is supposed to be called whenever a user - * joins a call (i.e. meeting or session). For instance, when - * users are joining a lesson session, or a tutor is joining an - * interview session. + * This event listener will be called whenever a user + * joins a call. For instance, when + * users are joining a lesson, or a tutor is joining an + * interview. */ async onJoinCall(data: unknown) { const result = await safe(async () => { const { callId } = onJoinCallPayload.parse(data); - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; - stdout.info(`User ${this.user.id} is joining call ${callId}.`); + const user = this.user; + // todo: add ghost as a member of the call + if (isGhost(user)) return stdout.warning("Unsupported"); + + stdout.info(`User ${user.id} is joining call ${callId}.`); + + const call = await calls.findById(callId); + if (!call) throw new Error("Call not found."); + + // todo: verify that the user can be member of the call + // todo: user should not be able to join the call before its start. // add user to the call by inserting row to call_members relation - await calls.addMember({ - userId: this.user.id, - callId: callId - }) + await calls.addMember({ + userId: user.id, + callId: callId, + }); - stdout.info(`User ${this.user.id} has joined call ${callId}.`); + stdout.info(`User ${user.id} has joined call ${callId}.`); // notify members that a new member has joined the call - this.broadcast( - Wss.ServerEvent.MemberJoinedCall, - callId.toString(), - { memberId: this.user.id } - ) + this.socket.broadcast + .to(this.asCallRoomId(callId)) + .emit(Wss.ServerEvent.MemberJoinedCall, { + userId: user.id, + }); }); if (result instanceof Error) stdout.error(result.message); } /* - * This event listener is supposed to be called whenever a user - * leaves a call (i.e. meeting or session). For instance, when - * users are leaving a lesson session, or a tutor is leaving an - * interview session. + * This event listener will be called whenever a user + * leaves a call. For instance, when users are leaving + * a lesson, or a tutor is leaving an interview. */ async onLeaveCall(data: unknown) { const result = await safe(async () => { const { callId } = onLeaveCallPayload.parse(data); - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; - stdout.info(`User ${this.user.id} is leaving call ${callId}.`); + const user = this.user; + if (isGhost(user)) return; + + stdout.info(`User ${user.id} is leaving call ${callId}.`); // remove user from the call by deleting the corresponding row from call_members relation await calls.removeMember({ - userId: this.user.id, - callId: callId - }) + userId: user.id, + callId: callId, + }); - stdout.info(`User ${this.user.id} has left call ${callId}.`); + stdout.info(`User ${user.id} has left call ${callId}.`); // notify members that a member has left the call - this.broadcast( - Wss.ServerEvent.MemberLeftCall, - callId.toString(), - { memberId: this.user.id } - ) + this.socket.broadcast + .to(callId.toString()) + .emit(Wss.ServerEvent.MemberLeftCall, { + userId: user.id, + }); }); if (result instanceof Error) stdout.error(result.message); } async joinRooms() { const error = await safe(async () => { - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; - const { list } = await rooms.findMemberRooms({ userId: this.user.id }); - const ids = list.map((roomId: number) => roomId.toString()); - this.socket.join(ids); + const user = this.user; + if (isGhost(user)) return; + + const { list } = await rooms.findMemberRooms({ userId: user.id }); + + this.socket.join(list.map((roomId) => this.asChatRoomId(roomId))); // private channel - this.socket.join(this.user.id.toString()); + this.socket.join(user.id.toString()); if (isStudent(this.user)) this.socket.join(Wss.Room.TutorsCache); if (isAdmin(this.user)) this.socket.join(Wss.Room.ServerStats); + + // todo: find user user call ids + // student => lessons + // tutor => lessons & interview + // interview => interviews + this.socket.join("call:1"); }); if (error instanceof Error) stdout.error(error.message); @@ -153,20 +162,13 @@ export class WssHandler { async peerOpened(ids: unknown) { const error = await safe(async () => { const { callId, peerId } = peerPayload.parse(ids); - - console.log({ - call: callId, - peer: peerId, - user: isUser(this.user) ? (this.user as IUser.Self).email : this.user, - }); + const user = this.user; const members = await calls.findCallMembers([callId]); if (isEmpty(members)) return; const memberIds = members.map((member) => member.userId); - const isMember = - isUser(this.user) && - memberIds.includes((this.user as IUser.Self).id); + const isMember = isUser(user) && memberIds.includes(user.id); const allowed = isMember || isGhost(this.user); if (!allowed) return; @@ -181,12 +183,13 @@ export class WssHandler { async registerPeer(data: unknown) { const result = await safe(async () => { const { peer } = registerPeerPayload.parse(data); - const id = isGhost(this.user) ? this.user : (this.user as IUser.Self).email; + const user = this.user; + const id = isGhost(user) ? user : user.email; stdout.info(`Register peer: ${peer} for ${id}`); - if (isGhost(this.user)) - await cache.peer.setGhostPeerId(getGhostCall(this.user), peer); - if (isTutor(this.user)) - await cache.peer.setUserPeerId((this.user as IUser.Self).id, peer); + + if (isGhost(user)) + await cache.peer.setGhostPeerId(getGhostCall(user), peer); + if (isTutor(user)) await cache.peer.setUserPeerId(user.id, peer); // notify peers to refetch the peer id if needed }); @@ -203,13 +206,14 @@ export class WssHandler { const members = await rooms.findRoomMembers({ roomIds: [roomId] }); if (isEmpty(members)) return; - const isMember = members.find((member) => member.id === (user as IUser.Self).id); + const isMember = members.find((member) => member.id === user.id); if (!isMember) - throw new Error(`User(${(user as IUser.Self).id}) isn't member of room Id: ${roomId}`); + throw new Error(`User(${user.id}) isn't member of room Id: ${roomId}`); - this.socket - .to(roomId.toString()) - .emit(Wss.ServerEvent.UserTyping, { roomId, userId: (user as IUser.Self).id }); + this.socket.to(roomId.toString()).emit(Wss.ServerEvent.UserTyping, { + roomId, + userId: user.id, + }); }); if (error instanceof Error) stdout.error(error.message); @@ -217,11 +221,13 @@ export class WssHandler { async sendMessage(data: unknown) { const error = safe(async () => { - if (isGhost(this.user)) return; + const user = this.user; + if (isGhost(user)) return; + const { roomId, text } = wss.message.send.parse(data); - const userId = (this.user as IUser.Self).id; + const userId = user.id; - console.log(`u:${userId} is send a message to r:${roomId}`); + stdout.log(`u:${userId} is send a message to r:${roomId}`); const members = await rooms.findRoomMembers({ roomIds: [roomId] }); if (!members) throw Error("Room not found"); @@ -237,7 +243,11 @@ export class WssHandler { roomId, }); - this.broadcast(Wss.ServerEvent.RoomMessage, roomId.toString(), message); + this.broadcast( + Wss.ServerEvent.RoomMessage, + this.asChatRoomId(roomId), + message + ); }); if (error instanceof Error) stdout.error(error); @@ -245,12 +255,14 @@ export class WssHandler { async updateMessage(data: unknown) { const error = await safe(async () => { - if (isGhost(this.user)) return; + const user = this.user; + if (isGhost(user)) return; + const { id, text } = updateMessagePayload.parse(data); const message = await messages.findById(id); if (!message || message.deleted) throw new Error("Message not found"); - const owner = message.userId === (this.user as IUser.Self).id; + const owner = message.userId === user.id; if (!owner) throw new Error("Forbidden"); const sanitized = sanitizeMessage(text); @@ -261,7 +273,7 @@ export class WssHandler { this.broadcast( Wss.ServerEvent.RoomMessageUpdated, - message.roomId.toString(), + this.asChatRoomId(message.roomId), updated ); }); @@ -271,20 +283,22 @@ export class WssHandler { async deleteMessage(data: unknown) { const error = safe(async () => { - if (isGhost(this.user)) return; - const { id }: { id: number } = withNamedId("id").parse(data) as { id: number }; + const user = this.user; + if (isGhost(user)) return; + + const { id }: { id: number } = withNamedId("id").parse(data); const message = await messages.findById(id); if (!message || message.deleted) throw new Error("Message not found"); - const owner = message.userId === (this.user as IUser.Self).id; + const owner = message.userId === user.id; if (!owner) throw new Error("Forbidden"); await messages.markAsDeleted(id); this.broadcast( Wss.ServerEvent.RoomMessageDeleted, - message.roomId.toString(), + this.asChatRoomId(message.roomId), { roomId: message.roomId, messageId: message.id, @@ -297,12 +311,12 @@ export class WssHandler { async toggleCamera(data: unknown) { const error = safe(async () => { - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; + const user = this.user; + if (isGhost(user)) return; const { call, camera } = toggleCameraPayload.parse(data); // todo: add validation this.broadcast(Wss.ServerEvent.CameraToggled, call.toString(), { - user: this.user.id, + user: user.id, camera, }); }); @@ -312,12 +326,12 @@ export class WssHandler { async toggleMic(data: unknown) { const error = safe(async () => { - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; + const user = this.user; + if (isGhost(user)) return; const { call, mic } = toggleMicPayload.parse(data); // todo: add validation this.broadcast(Wss.ServerEvent.MicToggled, call.toString(), { - user: this.user.id, + user: user.id, mic, }); }); @@ -336,14 +350,14 @@ export class WssHandler { async markMessageAsRead(data: unknown) { try { - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; + const user = this.user; + if (isGhost(user)) return; const messageId = wss.message.markMessageAsRead.parse(data).id; const message = await messages.findById(messageId); if (!message) throw new Error("Message not found"); - const userId = this.user.id; + const userId = user.id; if (userId !== message.userId) throw new Error("Unauthorized"); if (message.read) return console.log("Message is already marked as read".yellow); @@ -376,17 +390,17 @@ export class WssHandler { } async online() { - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; - const user = await users.update(this.user.id, { online: true }); - this.announceStatus(user); + const user = this.user; + if (isGhost(user)) return; + const info = await users.update(user.id, { online: true }); + this.announceStatus(info); } async offline() { - if (isGhost(this.user)) return; - this.user = this.user as IUser.Self; - const user = await users.update(this.user.id, { online: false }); - this.announceStatus(user); + const user = this.user; + if (isGhost(user)) return; + const info = await users.update(user.id, { online: false }); + this.announceStatus(info); } async emitServerStats() { @@ -403,12 +417,13 @@ export class WssHandler { */ async deregisterPeer() { // todo: notify peers that the current user got disconnected - const display = isGhost(this.user) ? this.user : (this.user as IUser.Self).email; + const user = this.user; + const display = isGhost(user) ? user : user.email; stdout.info(`Deregister peer for: ${display}`); - if (isGhost(this.user)) - return await cache.peer.removeGhostPeerId(getGhostCall(this.user)); - if (isTutor(this.user)) await cache.peer.removeUserPeerId((this.user as IUser.Self).id); + if (isGhost(user)) + return await cache.peer.removeGhostPeerId(getGhostCall(user)); + if (isTutor(user)) await cache.peer.removeUserPeerId(user.id); } async announceStatus(user: IUser.Self) { @@ -420,12 +435,22 @@ export class WssHandler { }); } } + + asCallRoomId(callId: number) { + return `call:${callId}`; + } + + asChatRoomId(roomId: number) { + return `room:${roomId}`; + } } export function wssHandler(socket: Socket) { const user = socket.request.user; if (!user) { - stdout.warning("(function) wssHandler: No user has been found in the request obj!") + stdout.warning( + "(function) wssHandler: No user has been found in the request obj!" + ); return; } return new WssHandler(socket, user); diff --git a/services/server/tests/wss/call.test.ts b/services/server/tests/wss/call.test.ts index 0b908b0df..8b99ba863 100644 --- a/services/server/tests/wss/call.test.ts +++ b/services/server/tests/wss/call.test.ts @@ -2,54 +2,63 @@ import { Api } from "@fixtures/api"; import db, { flush } from "@fixtures/db"; import { ClientSocket } from "@fixtures/wss"; import { IUser, Wss } from "@litespace/types"; -import { number } from "zod"; +import dayjs from "@/lib/dayjs"; +import { IRule } from "@litespace/types"; +import { Time } from "@litespace/sol/time"; +import { faker } from "@faker-js/faker/locale/ar"; +import { unpackRules } from "@litespace/sol/rule"; +import { expect } from "chai"; describe("calls test suite", () => { - let admin: IUser.LoginApiResponse; let tutor: IUser.LoginApiResponse; + let student: IUser.LoginApiResponse; - let callId: number; - - let adminSocket: ClientSocket; - let tutorSocket: ClientSocket; + let tutorApi: Api; + let studentApi: Api; beforeEach(async () => { await flush(); - const adminApi = await Api.forSuperAdmin(); - admin = await adminApi.findCurrentUser(); - - const tutorApi = await Api.forTutor(); + tutorApi = await Api.forTutor(); tutor = await tutorApi.findCurrentUser(); - let interview = await db.interview({ - interviewer: admin.user.id, - interviewee: tutor.user.id - }); - callId = interview.ids.call; - - adminSocket = new ClientSocket(admin.token); - tutorSocket = new ClientSocket(tutor.token); + studentApi = await Api.forStudent(); + student = await studentApi.findCurrentUser(); }); - it("should the user (client) emit an event when joining a call", async () => { - const asyncResult = adminSocket.wait(Wss.ServerEvent.MemberJoinedCall); - tutorSocket.joinCall(callId); + it("should broadcast the event when the user join the call", async () => { + const rule = await tutorApi.atlas.rule.create({ + start: dayjs.utc().startOf("day").toISOString(), + end: dayjs.utc().add(10, "day").startOf("day").toISOString(), + duration: 8 * 60, + frequency: IRule.Frequency.Daily, + time: Time.from("12:00").utc().format("railway"), + title: faker.lorem.words(3), + }); - const { memberId } = await asyncResult; + const unpackedRules = unpackRules({ + rules: [rule], + slots: [], + start: rule.start, + end: rule.end, + }); - expect(memberId).toBe(number); - expect(memberId).toEqual(tutor.user.id); - }); + const [selectedRule] = unpackedRules; + + const lesson = await studentApi.atlas.lesson.create({ + start: selectedRule.start, + duration: 30, + ruleId: rule.id, + tutorId: tutor.user.id, + }); - it("should the user (client) emit an event when leaving a call", async () => { - const asyncResult = adminSocket.wait(Wss.ServerEvent.MemberLeftCall); - tutorSocket.joinCall(callId); - tutorSocket.leaveCall(callId); + const tutorSocket = new ClientSocket(tutor.token); + const studentSocket = new ClientSocket(student.token); - const { memberId } = await asyncResult; + const result = studentSocket.wait(Wss.ServerEvent.MemberJoinedCall); + tutorSocket.joinCall(lesson.callId); - expect(memberId).toBe(number); - expect(memberId).toEqual(tutor.user.id); + const { userId } = await result; + expect(userId).to.be.eq(tutor.user.id); }); });