diff --git a/backend/app/main.py b/backend/app/main.py index 77c75c3ba95804278468ad1c7d5b97177c50f1d5..afae43c18065b80937a7090323fa2d99883c6963 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,6 @@ from typing import Annotated from fastapi import ( APIRouter, FastAPI, - Form, status, Depends, HTTPException, @@ -32,6 +31,7 @@ import config from security import jwt_cookie, get_jwt_user websocket_users = defaultdict(lambda: defaultdict(set)) +websocket_users_global = defaultdict(set) @asynccontextmanager @@ -442,10 +442,6 @@ def create_session( db: Session = Depends(get_db), current_user: schemas.User = Depends(get_jwt_user), ): - # if not check_user_level(current_user, models.UserType.TUTOR): - # raise HTTPException( - # status_code=401, detail="You do not have permission to create a session" - # ) rep = crud.create_session(db, current_user) rep.length = 0 @@ -616,22 +612,43 @@ def download_sessions_feedbacks( ) +async def send_websoket_add_to_session(session: schemas.Session, user_id: int): + if user_id in websocket_users_global: + for user_websocket in websocket_users_global[user_id]: + await user_websocket.send_text( + json.dumps( + { + "type": "session", + "action": "create", + "data": session.to_dict(), + } + ) + ) + + @sessionsRouter.post( "/{session_id}/users/{user_id}", status_code=status.HTTP_201_CREATED ) def add_user_to_session( session_id: int, user_id: int, + background_tasks: BackgroundTasks, db: Session = Depends(get_db), current_user: schemas.User = Depends(get_jwt_user), ): - # if not check_user_level(current_user, models.UserType.TUTOR): - # raise HTTPException( - # status_code=401, - # detail="You do not have permission to add a user to a session", - # ) - db_session = crud.get_session(db, session_id) + + if ( + db_session is not None + and current_user not in db_session.users + or db_session is None + and not check_user_level(current_user, models.UserType.TUTOR) + ): + raise HTTPException( + status_code=401, + detail="You do not have permission to add a user to a session", + ) + if db_session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -643,6 +660,12 @@ def add_user_to_session( db.commit() db.refresh(db_session) + background_tasks.add_task( + send_websoket_add_to_session, + schemas.Session.model_validate(db_session), + schemas.User.model_validate(db_user).id, + ) + @sessionsRouter.delete( "/{session_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT @@ -1000,6 +1023,45 @@ async def websocket_session( pass +@websocketRouter.websocket("/global") +async def websocket_session( + token: str, websocket: WebSocket, db: Session = Depends(get_db) +): + try: + payload = jwt.decode(token, config.JWT_SECRET_KEY, algorithms=["HS256"]) + except ExpiredSignatureError: + await websocket.close(code=1008, reason="Token expired") + return + except jwt.JWTError: + await websocket.close(code=1008, reason="Invalid token") + return + + current_user = crud.get_user(db, user_id=payload["subject"]["uid"]) + if current_user is None: + await websocket.close(code=1008, reason="Invalid user") + return + + await websocket.accept() + + websocket_users_global[current_user.id].add(websocket) + + try: + while True: + data = await websocket.receive_text() + for user_id, user_websockets in websocket_users_global.items(): + if user_id == current_user.id: + continue + + for user_websocket in user_websockets: + await user_websocket.send_text(data) + except: + websocket_users_global[current_user.id].remove(websocket) + try: + await websocket.close() + except: + pass + + @webhookRouter.post("/sessions", status_code=status.HTTP_202_ACCEPTED) async def webhook_session( webhook: schemas.CalComWebhook, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b4acc4938abd87a50f74c5546652ac8628cef6f6..a2a9b64dffaf17ab8093443bbb919bab8c82db37 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -34,6 +34,20 @@ class User(BaseModel): class Config: from_attributes = True + def to_dict(self): + return { + "id": self.id, + "email": self.email, + "nickname": self.nickname, + "type": self.type, + "availability": self.availability, + "is_active": self.is_active, + "ui_language": self.ui_language, + "home_language": self.home_language, + "target_language": self.target_language, + "birthdate": self.birthdate.isoformat() if self.birthdate else None, + } + class UserCreate(BaseModel): email: str @@ -99,6 +113,18 @@ class Session(BaseModel): class Config: from_attributes = True + def to_dict(self): + return { + "id": self.id, + "created_at": self.created_at.isoformat(), + "is_active": self.is_active, + "users": [user.to_dict() for user in self.users], + "start_time": self.start_time.isoformat(), + "end_time": self.end_time.isoformat(), + "language": self.language, + "length": self.length, + } + class SessionUpdate(BaseModel): is_active: bool | None = None diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index a9dbdc6777a5508c63fab398f24a888e678f959f..ff996950da42564b2d1fdbb7ac0993ce7609e306 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -47,7 +47,8 @@ "noContact": "Add a contact to get started", "noCurrentOrFutureSessions": "No session in progress or planned", "pastSessions": "Completed sessions", - "plannedSessions": "Scheduled sessions" + "plannedSessions": "Scheduled sessions", + "sessionAdded": "You have been added to a session with {users}" }, "signup": { "title": "Register", diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index 5e42f62b213adf2f2c59e4d9de9c045edb636022..de6627901980686a039465826cb3c763e6535297 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -47,7 +47,8 @@ "newFirstContact": "Ajouter un premier contact", "actions": "Actions", "date": "Date", - "participants": "Participants" + "participants": "Participants", + "sessionAdded": "Vous avez été ajouté à une session avec {users}" }, "login": { "email": "E-mail", diff --git a/frontend/src/lib/types/user.ts b/frontend/src/lib/types/user.ts index 1d9e25291992044c526ac5f42ea4980103b4d4ce..b05a1d01f889fe2d041b950baf5714b0a0788593 100644 --- a/frontend/src/lib/types/user.ts +++ b/frontend/src/lib/types/user.ts @@ -1,8 +1,10 @@ import { createUserAPI, getUsersAPI, patchUserAPI } from '$lib/api/users'; +import config from '$lib/config'; import { parseToLocalDate } from '$lib/utils/date'; import { toastAlert } from '$lib/utils/toasts'; import { sha256 } from 'js-sha256'; -import { get, writable } from 'svelte/store'; +import { get, writable, type Writable } from 'svelte/store'; +import Session from './session'; const { subscribe, set, update } = writable<User[]>([]); @@ -31,6 +33,9 @@ export default class User { private _calcom_link: string | null; private _study_id: number | null; private _last_survey: Date | null; + private _ws_connected: boolean = false; + private _ws: WebSocket | null = null; + private _sessions_added: Writable<Session[]> = writable([]); private constructor( id: number, @@ -126,6 +131,10 @@ export default class User { return this._last_survey; } + get sessions_added(): Writable<Session[]> { + return this._sessions_added; + } + equals<T>(obj: T): boolean { if (obj === null || obj === undefined) return false; if (!(obj instanceof User)) return false; @@ -228,6 +237,43 @@ export default class User { return userFinal; } + public wsConnect(jwt: string) { + if (this._ws_connected) return; + + this._ws = new WebSocket(`${config.WS_URL}/global?token=${jwt}`); + + this._ws.onopen = () => { + this._ws_connected = true; + }; + + this._ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data['type'] === 'session') { + if (data['action'] === 'create') { + const session = Session.parse(data['data']); + if (session) { + this._sessions_added.update((sessions) => { + if (!sessions.find((s) => s.id === session.id)) { + return [...sessions, session]; + } + return sessions.map((s) => (s.id === session.id ? session : s)); + }); + + return; + } + } + } + console.error('Failed to parse ws:', data); + }; + + this._ws.onclose = () => { + this._ws = null; + this._ws_connected = false; + setTimeout(() => this.wsConnect(jwt), 1000); + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any static parse(json: any): User { if (json === null || json === undefined) { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index d200c9f18528a75f137b466ad91c8d936bd2c8fd..273ecaea4afcd336da08bc0fef67521e00d57a66 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,9 +4,24 @@ import { t } from '$lib/services/i18n'; import Header from './Header.svelte'; import type { PageData } from './$types'; + import { toastSuccess } from '$lib/utils/toasts'; let { data, children }: { data: PageData; children: any } = $props(); let user = data.user; + + user?.sessions_added.subscribe((sessions) => { + if (sessions.length > 0) { + let lastSession = sessions[sessions.length - 1]; + toastSuccess( + $t('home.sessionAdded', { + users: lastSession.users + .filter((u) => u.id != user?.id) + .map((user) => user.nickname) + .join(', ') + }) + ); + } + }); </script> <svelte:head> diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..8140c1aa9c82c2ef506669ada8877d268a658b72 --- /dev/null +++ b/frontend/src/routes/+page.server.ts @@ -0,0 +1,9 @@ +import { type ServerLoad } from '@sveltejs/kit'; + +export const load: ServerLoad = async ({ locals }) => { + if (locals.user == null || locals.user == undefined) { + return {}; + } + + return { jwt: locals.jwt, user: locals.user }; +}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 4b5ece68436f1e598bffc54c1b8e5858feb87b37..71f9cd1297fbfbd5e5907139f9ab13cbf7592d22 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -23,6 +23,7 @@ let { data } = $props(); let user = data.user!; + let jwt = data.jwt!; let contacts: User[] = $state(data.contacts); let contact: User | undefined = $state(data.contact); let contactSessions: Session[] = $state(data.sessions); @@ -32,6 +33,17 @@ let showTerminatedSessions = $state(false); + user.sessions_added.subscribe((sessions) => { + if (!contact) return; + + sessions = sessions.filter((s) => s.users.some((u) => u.id === contact?.id)); + + contactSessions = [ + ...contactSessions, + ...sessions.filter((s) => !contactSessions.some((cs) => cs.id === s.id)) + ].sort((a, b) => b.start_time.getTime() - a.start_time.getTime()); + }); + async function selectContact(c: User | undefined) { showTerminatedSessions = false; contact = c; @@ -46,6 +58,8 @@ } onMount(async () => { + user.wsConnect(jwt); + (function (C: any, A: any, L: any) { let p = function (a: any, ar: any) { a.q.push(ar); diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts index 62a009b9964eab8a83544ae481bf25066b59087d..390b94d641752c7b574dcd66e16ec41eeb3f6bcb 100644 --- a/frontend/src/routes/+page.ts +++ b/frontend/src/routes/+page.ts @@ -3,8 +3,9 @@ import Session from '$lib/types/session'; import User from '$lib/types/user'; import type { Load } from '@sveltejs/kit'; -export const load: Load = async ({ parent, fetch }) => { +export const load: Load = async ({ parent, fetch, data }) => { const { user } = await parent(); + const jwt = data?.jwt; const contacts = User.parseAll(await getUserContactsAPI(fetch, user.id)); @@ -12,7 +13,8 @@ export const load: Load = async ({ parent, fetch }) => { return { contacts, contact: undefined, - sessions: [] + sessions: [], + jwt }; } @@ -24,6 +26,7 @@ export const load: Load = async ({ parent, fetch }) => { return { contacts, contact, - sessions + sessions, + jwt }; };