diff --git a/frontend/src/models/websocket.ts b/frontend/src/models/websocket.ts index ccea235a..67853e70 100644 --- a/frontend/src/models/websocket.ts +++ b/frontend/src/models/websocket.ts @@ -8,12 +8,46 @@ export type WSAction = | 'create_label' | 'update_label' | 'delete_label' + | 'get_tasks' + | 'get_completed_tasks' + | 'get_task' + | 'create_task' + | 'update_task' + | 'delete_task' + | 'skip_task' + | 'update_due_date' + | 'complete_task' + | 'uncomplete_task' + | 'get_task_history' + | 'get_app_tokens' + | 'create_app_token' + | 'delete_app_token' + | 'update_notification_settings' + | 'get_user_profile' + | 'update_password' export interface WSActionPayloads { get_user_labels: void create_label: Omit update_label: Label delete_label: { id: number } + get_tasks: void + get_completed_tasks: { limit?: number; page?: number } + get_task: { id: number } + create_task: Omit + update_task: Task + delete_task: { id: number } + skip_task: { id: number } + update_due_date: { id: number; due_date: string } + complete_task: { id: number } + uncomplete_task: { id: number } + get_task_history: { id: number } + get_app_tokens: void + create_app_token: { name: string; scopes: string[]; expiration: number } + delete_app_token: { id: string } + update_notification_settings: { provider: NotificationType; triggers: NotificationTriggerOptions } + get_user_profile: void + update_password: { password: string } } export type WSEvent = diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 38de052c..ad15e444 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,7 +1,11 @@ import { RefreshToken } from '@/api/auth' import { TOKEN_REFRESH_THRESHOLD_MS } from '@/constants/time' +import WebSocketManager from './websocket' +import { store } from '@/store/store' +import { WSAction } from '@/models/websocket' const API_URL = import.meta.env.VITE_APP_API_URL +const HTTP_STATUS_NO_CONTENT = 204 type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' @@ -9,6 +13,11 @@ type FailureResponse = { error: string } +interface RequestToWSMapping { + action: WSAction + getData: (url: string, body: unknown) => any +} + let isRefreshingAccessToken = false const isTokenNearExpiration = () => { const now = new Date() @@ -17,6 +26,82 @@ const isTokenNearExpiration = () => { return now.getTime() + TOKEN_REFRESH_THRESHOLD_MS > expire.getTime() } +// Map HTTP requests to WebSocket actions +const mapRequestToWebSocket = ( + url: string, + method: RequestMethod +): RequestToWSMapping | null => { + // Extract ID from URL if present + const idMatch = url.match(/\/(\d+)/) + const id = idMatch ? parseInt(idMatch[1], 10) : undefined + + // Labels endpoints + if (url === '/labels' && method === 'GET') { + return { action: 'get_user_labels', getData: () => undefined } + } + if (url === '/labels' && method === 'POST') { + return { action: 'create_label', getData: (_url, body) => body } + } + if (url === '/labels' && method === 'PUT') { + return { action: 'update_label', getData: (_url, body) => body } + } + if (url.startsWith('/labels/') && method === 'DELETE' && id) { + return { action: 'delete_label', getData: () => ({ id }) } + } + + // Tasks endpoints + if (url === '/tasks/' && method === 'GET') { + return { action: 'get_tasks', getData: () => undefined } + } + if (url === '/tasks/completed' && method === 'GET') { + return { action: 'get_completed_tasks', getData: () => ({}) } + } + if (url === '/tasks/' && method === 'POST') { + return { action: 'create_task', getData: (_url, body) => body } + } + if (url === '/tasks/' && method === 'PUT') { + return { action: 'update_task', getData: (_url, body) => body } + } + if (url.startsWith('/tasks/') && url.endsWith('/do') && method === 'POST' && id) { + return { action: 'complete_task', getData: () => ({ id }) } + } + if (url.startsWith('/tasks/') && url.endsWith('/skip') && method === 'POST' && id) { + return { action: 'skip_task', getData: () => ({ id }) } + } + if (url.startsWith('/tasks/') && url.endsWith('/dueDate') && method === 'PUT' && id) { + return { action: 'update_due_date', getData: (_url, body: any) => ({ id, due_date: body.due_date }) } + } + if (url.startsWith('/tasks/') && url.endsWith('/history') && method === 'GET' && id) { + return { action: 'get_task_history', getData: () => ({ id }) } + } + if (url.startsWith('/tasks/') && method === 'DELETE' && id) { + return { action: 'delete_task', getData: () => ({ id }) } + } + + // User/Token endpoints + if (url === '/users/tokens' && method === 'GET') { + return { action: 'get_app_tokens', getData: () => undefined } + } + if (url === '/users/tokens' && method === 'POST') { + return { action: 'create_app_token', getData: (_url, body) => body } + } + if (url.startsWith('/users/tokens/') && method === 'DELETE') { + const tokenId = url.split('/').pop() + return { action: 'delete_app_token', getData: () => ({ id: tokenId }) } + } + if (url === '/users/profile' && method === 'GET') { + return { action: 'get_user_profile', getData: () => undefined } + } + if (url === '/users/notifications' && method === 'PUT') { + return { action: 'update_notification_settings', getData: (_url, body) => body } + } + if (url === '/users/change_password' && method === 'PUT') { + return { action: 'update_password', getData: (_url, body) => body } + } + + return null +} + export const isTokenValid = (): boolean => { const token = localStorage.getItem('ca_token') if (token) { @@ -67,6 +152,39 @@ export async function Request( throw new Error('User is not authenticated') } + // Check if WebSocket should be used + const state = store.getState() + const useWebSocket = state.featureFlags.useWebsockets && requiresAuth + + if (useWebSocket) { + const wsManager = WebSocketManager.getInstance() + if (wsManager.isConnected()) { + const wsMapping = mapRequestToWebSocket(url, method) + + if (wsMapping) { + try { + const wsData = wsMapping.getData(url, body) + const response = await wsManager.sendRequest(wsMapping.action, wsData) + + if (response.status === HTTP_STATUS_NO_CONTENT) { + return {} as SuccessfulResponse + } + + if (response.status >= 400) { + const errorData = response.data as FailureResponse + throw new Error(errorData.error || 'Request failed') + } + + return response.data as SuccessfulResponse + } catch (error) { + // If WebSocket request fails, fall back to HTTP + console.debug('WebSocket request failed, falling back to HTTP:', error) + } + } + } + } + + // Fall back to HTTP request const fullURL = `${API_URL}/api/v1${url}` const headers: HeadersInit = { @@ -89,7 +207,6 @@ export async function Request( } const response: Response = await fetch(fullURL, options) - const HTTP_STATUS_NO_CONTENT = 204 if (response.status === HTTP_STATUS_NO_CONTENT) { return {} as SuccessfulResponse } diff --git a/frontend/src/utils/websocket.ts b/frontend/src/utils/websocket.ts index 3204d4d0..a6a16953 100644 --- a/frontend/src/utils/websocket.ts +++ b/frontend/src/utils/websocket.ts @@ -3,6 +3,7 @@ import { WSAction, WSEventPayloads, WSRequest, + WSActionPayloads, } from '@/models/websocket' import { store } from '@/store/store' import { wsConnecting, wsConnected, wsDisconnected } from '@/store/wsSlice' @@ -200,6 +201,69 @@ export class WebSocketManager { this.socket!.send(JSON.stringify(request)) } + async sendRequest( + action: T, + data?: WSActionPayloads[T], + timeout: number = 5000 + ): Promise<{ status: number; data: any }> { + if (!this.isConnected()) { + throw new Error('WebSocket is not connected') + } + + // Generate a unique request ID using crypto API if available, fallback to timestamp-based ID + const requestId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).substring(2, 15)}` + + const request: WSRequest = { + requestId, + action, + data, + } + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup() + reject(new Error('Request timed out')) + }, timeout) + + const handleMessage = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data) + // Validate response structure before accessing properties + if ( + response && + typeof response === 'object' && + 'requestId' in response && + 'status' in response && + 'data' in response && + response.requestId === requestId + ) { + cleanup() + resolve({ status: response.status, data: response.data }) + } + } catch { + // Ignore parsing errors for messages not meant for us + } + } + + const cleanup = () => { + clearTimeout(timeoutId) + if (this.socket) { + this.socket.removeEventListener('message', handleMessage) + } + } + + if (this.socket) { + this.socket.addEventListener('message', handleMessage) + this.socket.send(JSON.stringify(request)) + } else { + cleanup() + reject(new Error('WebSocket is not connected')) + } + }) + } + private scheduleReconnect() { if (this.manualClose || !this.enabled) { return diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 160a3078..09fb5c27 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1084,6 +1084,11 @@ resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz" integrity sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw== +"@rollup/rollup-linux-x64-musl@4.45.1": + version "4.45.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz" + integrity sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw== + "@sinclair/typebox@^0.34.0": version "0.34.41" resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz" @@ -1118,6 +1123,11 @@ resolved "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.0.tgz" integrity sha512-51n4P4nv6rblXyH3zCEktvmR9uSAZ7+zbfeby0sxbj8LS/IKuVd7iCwD5dwMj4CxG9Fs+HgjN73dLQF/OerHhg== +"@swc/core-linux-x64-musl@1.13.0": + version "1.13.0" + resolved "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.0.tgz" + integrity sha512-VMqelgvnXs27eQyhDf1S2O2MxSdchIH7c1tkxODRtu9eotcAeniNNgqqLjZ5ML0MGeRk/WpbsAY/GWi7eSpiHw== + "@swc/core@^1.12.11": version "1.13.0" resolved "https://registry.npmjs.org/@swc/core/-/core-1.13.0.tgz" @@ -1450,6 +1460,11 @@ resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz" integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + "@vitejs/plugin-react-swc@^3.7.2": version "3.11.0" resolved "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz"