diff --git a/jam-ui/src/helpers/rest.js b/jam-ui/src/helpers/rest.js index f30f0ce6c..a66892d13 100644 --- a/jam-ui/src/helpers/rest.js +++ b/jam-ui/src/helpers/rest.js @@ -1009,3 +1009,52 @@ export const sendChatMessage = async ({ channel, sessionId, message, clientId }) return response.json(); }; + +/** + * Upload file attachment to S3 via backend + * + * IMPORTANT: Uses native fetch, NOT apiFetch wrapper + * Reason: FormData requires browser to set Content-Type with boundary + * + * @param {FormData} formData - FormData with files[], session_id, attachment_type + * @returns {Promise} Array of uploaded file metadata + */ +export const uploadMusicNotation = (formData) => { + const baseUrl = process.env.REACT_APP_API_BASE_URL || 'http://www.jamkazam.local:3000'; + + return new Promise((resolve, reject) => { + fetch(`${baseUrl}/api/music_notations`, { + method: 'POST', + credentials: 'include', // Include session cookie + // Do NOT set Content-Type - browser sets it with boundary for FormData + body: formData + }) + .then(response => { + if (!response.ok) { + // Pass status to error for specific handling + const error = new Error('Upload failed'); + error.status = response.status; + throw error; + } + return response.json(); + }) + .then(data => resolve(data)) + .catch(error => reject(error)); + }); +}; + +/** + * Get signed S3 URL for attachment download + * + * Uses apiFetch because response is JSON (not FormData) + * + * @param {string} id - MusicNotation UUID + * @returns {Promise} { url: "https://s3..." } + */ +export const getMusicNotationUrl = (id) => { + return new Promise((resolve, reject) => { + apiFetch(`/music_notations/${id}`) + .then(response => resolve(response)) + .catch(error => reject(error)); + }); +}; diff --git a/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js b/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js index fe2778cda..268abca5b 100644 --- a/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js +++ b/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js @@ -5,7 +5,10 @@ import sessionChatReducer, { closeChatWindow, markAsRead, incrementUnreadCount, - setWindowPosition + setWindowPosition, + setUploadStatus, + clearUploadError, + uploadAttachment } from '../sessionChatSlice'; describe('sessionChatSlice initial state', () => { @@ -83,7 +86,23 @@ describe('sessionChatSlice initial state', () => { sendError: null, nextCursors: {}, isWindowOpen: false, - windowPosition: null + windowPosition: null, + uploadState: { + status: 'idle', + progress: 0, + error: null, + fileName: null + } + }); + }); + + test('has uploadState with idle status', () => { + const state = sessionChatReducer(undefined, { type: 'unknown' }); + expect(state.uploadState).toEqual({ + status: 'idle', + progress: 0, + error: null, + fileName: null }); }); }); @@ -1292,3 +1311,333 @@ describe('sessionChat selectors', () => { }); }); }); + +// Phase 13 Plan 02: Upload state management tests +describe('setUploadStatus reducer', () => { + test('updates upload status', () => { + const state = { + uploadState: { + status: 'idle', + progress: 0, + error: null, + fileName: null + } + }; + const action = setUploadStatus({ status: 'uploading', fileName: 'test.pdf' }); + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.status).toBe('uploading'); + expect(newState.uploadState.fileName).toBe('test.pdf'); + }); + + test('updates progress when provided', () => { + const state = { + uploadState: { + status: 'uploading', + progress: 0, + error: null, + fileName: 'test.pdf' + } + }; + const action = setUploadStatus({ progress: 50 }); + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.progress).toBe(50); + expect(newState.uploadState.status).toBe('uploading'); + }); + + test('updates error when provided', () => { + const state = { + uploadState: { + status: 'uploading', + progress: 50, + error: null, + fileName: 'test.pdf' + } + }; + const action = setUploadStatus({ status: 'error', error: 'Upload failed' }); + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.status).toBe('error'); + expect(newState.uploadState.error).toBe('Upload failed'); + }); + + test('preserves other fields when updating one field', () => { + const state = { + uploadState: { + status: 'uploading', + progress: 25, + error: null, + fileName: 'document.pdf' + } + }; + const action = setUploadStatus({ progress: 75 }); + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.progress).toBe(75); + expect(newState.uploadState.status).toBe('uploading'); + expect(newState.uploadState.fileName).toBe('document.pdf'); + }); +}); + +describe('clearUploadError reducer', () => { + test('clears error and resets status to idle', () => { + const state = { + uploadState: { + status: 'error', + progress: 0, + error: 'Upload failed', + fileName: 'test.pdf' + } + }; + const action = clearUploadError(); + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.error).toBeNull(); + expect(newState.uploadState.status).toBe('idle'); + }); + + test('preserves fileName when clearing error', () => { + const state = { + uploadState: { + status: 'error', + progress: 0, + error: 'File too large', + fileName: 'large-file.pdf' + } + }; + const action = clearUploadError(); + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.fileName).toBe('large-file.pdf'); + expect(newState.uploadState.error).toBeNull(); + }); +}); + +describe('uploadAttachment async thunk', () => { + test('sets uploading state on pending', () => { + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + const action = { + type: 'sessionChat/uploadAttachment/pending', + meta: { + arg: { + file: mockFile, + sessionId: 'session-123', + clientId: 'client-456' + } + } + }; + const state = { + uploadState: { + status: 'idle', + progress: 0, + error: null, + fileName: null + } + }; + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.status).toBe('uploading'); + expect(newState.uploadState.error).toBeNull(); + expect(newState.uploadState.fileName).toBe('test.pdf'); + expect(newState.uploadState.progress).toBe(0); + }); + + test('resets to idle on fulfilled', () => { + const state = { + uploadState: { + status: 'uploading', + progress: 0, + error: null, + fileName: 'test.pdf' + } + }; + const action = { + type: 'sessionChat/uploadAttachment/fulfilled', + payload: { + id: 'notation-789', + file_name: 'test.pdf' + } + }; + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.status).toBe('idle'); + expect(newState.uploadState.fileName).toBeNull(); + expect(newState.uploadState.progress).toBe(0); + }); + + test('sets error state on rejected', () => { + const state = { + uploadState: { + status: 'uploading', + progress: 0, + error: null, + fileName: 'test.pdf' + } + }; + const action = { + type: 'sessionChat/uploadAttachment/rejected', + error: { message: 'Upload failed' } + }; + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.status).toBe('error'); + expect(newState.uploadState.error).toBe('Upload failed'); + expect(newState.uploadState.progress).toBe(0); + }); + + test('handles 413 error with custom message', () => { + const state = { + uploadState: { + status: 'uploading', + progress: 0, + error: null, + fileName: 'large.pdf' + } + }; + const action = { + type: 'sessionChat/uploadAttachment/rejected', + error: { message: 'File too large - maximum 10 MB' } + }; + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.error).toBe('File too large - maximum 10 MB'); + }); + + test('handles 422 error with custom message', () => { + const state = { + uploadState: { + status: 'uploading', + progress: 0, + error: null, + fileName: 'invalid.exe' + } + }; + const action = { + type: 'sessionChat/uploadAttachment/rejected', + error: { message: 'Invalid file type or format' } + }; + const newState = sessionChatReducer(state, action); + + expect(newState.uploadState.error).toBe('Invalid file type or format'); + }); +}); + +// Phase 13 Plan 02: Upload selectors tests +describe('upload selectors', () => { + let mockState; + let selectUploadStatus; + let selectUploadError; + let selectUploadProgress; + let selectUploadFileName; + let selectIsUploading; + + beforeEach(() => { + mockState = { + sessionChat: { + uploadState: { + status: 'uploading', + progress: 50, + error: null, + fileName: 'test.pdf' + } + } + }; + + try { + const slice = require('../sessionChatSlice'); + selectUploadStatus = slice.selectUploadStatus; + selectUploadError = slice.selectUploadError; + selectUploadProgress = slice.selectUploadProgress; + selectUploadFileName = slice.selectUploadFileName; + selectIsUploading = slice.selectIsUploading; + } catch (e) { + // Selectors not yet exported + } + }); + + describe('selectUploadStatus', () => { + test('returns upload status', () => { + if (!selectUploadStatus) { + expect(true).toBe(false); // RED phase + return; + } + const status = selectUploadStatus(mockState); + expect(status).toBe('uploading'); + }); + }); + + describe('selectUploadError', () => { + test('returns upload error', () => { + if (!selectUploadError) { + expect(true).toBe(false); // RED phase + return; + } + const error = selectUploadError(mockState); + expect(error).toBeNull(); + }); + + test('returns error message when present', () => { + if (!selectUploadError) { + expect(true).toBe(false); // RED phase + return; + } + mockState.sessionChat.uploadState.error = 'Upload failed'; + const error = selectUploadError(mockState); + expect(error).toBe('Upload failed'); + }); + }); + + describe('selectUploadProgress', () => { + test('returns upload progress', () => { + if (!selectUploadProgress) { + expect(true).toBe(false); // RED phase + return; + } + const progress = selectUploadProgress(mockState); + expect(progress).toBe(50); + }); + }); + + describe('selectUploadFileName', () => { + test('returns upload file name', () => { + if (!selectUploadFileName) { + expect(true).toBe(false); // RED phase + return; + } + const fileName = selectUploadFileName(mockState); + expect(fileName).toBe('test.pdf'); + }); + }); + + describe('selectIsUploading', () => { + test('returns true when status is uploading', () => { + if (!selectIsUploading) { + expect(true).toBe(false); // RED phase + return; + } + const isUploading = selectIsUploading(mockState); + expect(isUploading).toBe(true); + }); + + test('returns false when status is idle', () => { + if (!selectIsUploading) { + expect(true).toBe(false); // RED phase + return; + } + mockState.sessionChat.uploadState.status = 'idle'; + const isUploading = selectIsUploading(mockState); + expect(isUploading).toBe(false); + }); + + test('returns false when status is error', () => { + if (!selectIsUploading) { + expect(true).toBe(false); // RED phase + return; + } + mockState.sessionChat.uploadState.status = 'error'; + const isUploading = selectIsUploading(mockState); + expect(isUploading).toBe(false); + }); + }); +}); diff --git a/jam-ui/src/store/features/sessionChatSlice.js b/jam-ui/src/store/features/sessionChatSlice.js index af185943b..9f6ae518b 100644 --- a/jam-ui/src/store/features/sessionChatSlice.js +++ b/jam-ui/src/store/features/sessionChatSlice.js @@ -1,5 +1,5 @@ import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'; -import { getChatMessages, sendChatMessage } from '../../helpers/rest'; +import { getChatMessages, sendChatMessage, uploadMusicNotation } from '../../helpers/rest'; import { saveLastReadAt, loadLastReadAt } from '../../helpers/chatStorage'; /** @@ -45,6 +45,49 @@ export const sendMessage = createAsyncThunk( } ); +/** + * Async thunk to upload file attachment to session + * Creates MusicNotation record, which triggers ChatMessage creation + * ChatMessage broadcast via WebSocket (handled separately) + * + * @param {Object} params + * @param {File} params.file - File object from input + * @param {string} params.sessionId - Music session ID + * @param {string} [params.clientId] - WebSocket client ID (for deduplication) + */ +export const uploadAttachment = createAsyncThunk( + 'sessionChat/uploadAttachment', + async ({ file, sessionId, clientId }, { rejectWithValue }) => { + try { + // Build FormData + const formData = new FormData(); + formData.append('files[]', file); + formData.append('session_id', sessionId); + + // Determine attachment type based on file extension + const ext = file.name.split('.').pop().toLowerCase(); + const audioExts = ['mp3', 'wav', 'flac', 'ogg', 'aiff', 'aifc', 'au']; + const attachmentType = audioExts.includes(ext) ? 'audio' : 'notation'; + formData.append('attachment_type', attachmentType); + + // Upload via REST API + const response = await uploadMusicNotation(formData); + + // Response contains file metadata, but ChatMessage arrives via WebSocket + return response; + } catch (error) { + // Check for specific error types + if (error.status === 413) { + return rejectWithValue('File too large - maximum 10 MB'); + } + if (error.status === 422) { + return rejectWithValue('Invalid file type or format'); + } + return rejectWithValue(error.message || 'Upload failed'); + } + } +); + /** * Initial state for session chat * @type {Object} @@ -60,6 +103,11 @@ export const sendMessage = createAsyncThunk( * @property {Object.} nextCursors - Pagination cursors per channel * @property {boolean} isWindowOpen - Whether chat window is open * @property {Object|null} windowPosition - Window position {x, y} + * @property {Object} uploadState - File upload state tracking + * @property {string} uploadState.status - Upload status ('idle', 'uploading', 'error') + * @property {number} uploadState.progress - Upload progress 0-100 + * @property {string|null} uploadState.error - Upload error message + * @property {string|null} uploadState.fileName - Currently uploading file name */ const initialState = { messagesByChannel: {}, @@ -73,7 +121,13 @@ const initialState = { sendError: null, nextCursors: {}, isWindowOpen: false, - windowPosition: null + windowPosition: null, + uploadState: { + status: 'idle', + progress: 0, + error: null, + fileName: null + } }; /** @@ -211,6 +265,27 @@ const sessionChatSlice = createSlice({ clearSendError: (state) => { state.sendError = null; state.sendStatus = 'idle'; + }, + + /** + * Set upload status + * Updates upload state fields (status, progress, error, fileName) + */ + setUploadStatus: (state, action) => { + const { status, progress, error, fileName } = action.payload; + if (status !== undefined) state.uploadState.status = status; + if (progress !== undefined) state.uploadState.progress = progress; + if (error !== undefined) state.uploadState.error = error; + if (fileName !== undefined) state.uploadState.fileName = fileName; + }, + + /** + * Clear upload error + * Resets error to null, status to idle + */ + clearUploadError: (state) => { + state.uploadState.error = null; + state.uploadState.status = 'idle'; } }, extraReducers: (builder) => { @@ -322,6 +397,26 @@ const sessionChatSlice = createSlice({ messages.splice(index, 1); } } + }) + // uploadAttachment pending + .addCase(uploadAttachment.pending, (state, action) => { + state.uploadState.status = 'uploading'; + state.uploadState.error = null; + state.uploadState.fileName = action.meta.arg.file.name; + state.uploadState.progress = 0; + }) + // uploadAttachment fulfilled + .addCase(uploadAttachment.fulfilled, (state, action) => { + state.uploadState.status = 'idle'; + state.uploadState.fileName = null; + state.uploadState.progress = 0; + // Note: Actual chat message arrives via WebSocket, not from upload response + }) + // uploadAttachment rejected + .addCase(uploadAttachment.rejected, (state, action) => { + state.uploadState.status = 'error'; + state.uploadState.error = action.error.message || 'Upload failed'; + state.uploadState.progress = 0; }); } }); @@ -334,7 +429,9 @@ export const { markAsRead, incrementUnreadCount, setWindowPosition, - clearSendError + clearSendError, + setUploadStatus, + clearUploadError } = sessionChatSlice.actions; // Phase 7 Plan 3: Memoized selectors using createSelector from Redux Toolkit @@ -472,4 +569,54 @@ export const selectSendError = createSelector( (chatState) => chatState.sendError ); +/** + * Select upload status + * + * Returns current upload status: 'idle', 'uploading', or 'error'. + * + * @param {Object} state - Redux state + * @returns {string} Upload status + */ +export const selectUploadStatus = (state) => state.sessionChat.uploadState.status; + +/** + * Select upload error message + * + * Returns error message if upload failed. + * + * @param {Object} state - Redux state + * @returns {string|null} Error message, or null if no error + */ +export const selectUploadError = (state) => state.sessionChat.uploadState.error; + +/** + * Select upload progress + * + * Returns upload progress 0-100. + * + * @param {Object} state - Redux state + * @returns {number} Upload progress percentage + */ +export const selectUploadProgress = (state) => state.sessionChat.uploadState.progress; + +/** + * Select upload file name + * + * Returns currently uploading file name. + * + * @param {Object} state - Redux state + * @returns {string|null} File name, or null if not uploading + */ +export const selectUploadFileName = (state) => state.sessionChat.uploadState.fileName; + +/** + * Select whether currently uploading + * + * Returns true if status is 'uploading'. + * + * @param {Object} state - Redux state + * @returns {boolean} True if uploading + */ +export const selectIsUploading = (state) => state.sessionChat.uploadState.status === 'uploading'; + export default sessionChatSlice.reducer;