From 48ff1dfbb150052340646cf75ce159dfdafe23dc Mon Sep 17 00:00:00 2001 From: Nuwan Date: Mon, 2 Feb 2026 18:54:43 +0530 Subject: [PATCH] docs(12-01): document legacy AttachmentStore implementation - Comprehensive analysis of CoffeeScript Reflux store patterns - Upload flow breakdown with FormData construction - Client-side validation logic (10 MB limit) - Hidden file input trigger pattern - Error handling for 413/422 responses - Integration points with ChatStore and dialog system - React port patterns and implementation checklist - 538 lines, 42 code examples ATTACHMENT_LEGACY.md: .planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_LEGACY.md --- .../docs/ATTACHMENT_LEGACY.md | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 .planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_LEGACY.md diff --git a/.planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_LEGACY.md b/.planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_LEGACY.md new file mode 100644 index 000000000..0e1396d1c --- /dev/null +++ b/.planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_LEGACY.md @@ -0,0 +1,538 @@ +# Legacy AttachmentStore Implementation - Reference Documentation + +**Source:** `web/app/assets/javascripts/react-components/stores/AttachmentStore.js.coffee` +**Purpose:** Reference documentation for porting file upload functionality to React/Redux +**Date:** 2026-02-02 + +--- + +## Overview + +The legacy AttachmentStore is a **Reflux store** written in CoffeeScript that handles file attachment uploads for both lesson chat and session chat. It provides client-side validation, FormData construction, AJAX upload, and error handling. The store integrates with the legacy ChatStore to trigger chat message creation after successful uploads. + +**Key characteristics:** +- Reflux store pattern (event-based state management) +- Supports two attachment types: `notation` and `audio` +- Client-side validation (10 MB limit) +- jQuery AJAX for multipart/form-data uploads +- Hidden file input trigger pattern +- Separate upload flows for lesson vs session context + +--- + +## 1. File Structure + +**Location:** `web/app/assets/javascripts/react-components/stores/AttachmentStore.js.coffee` + +**Store Pattern:** Reflux.createStore with listenables + +**Public Methods (Actions):** +- `onStartAttachRecording(lessonId, sessionId)` - Triggers recording selector dialog +- `onStartAttachNotation(lessonId, sessionId)` - Triggers notation file input +- `onStartAttachAudio(lessonId, sessionId)` - Triggers audio file input +- `onUploadNotations(notations, doneCallback, failCallback)` - Uploads notation files +- `onUploadAudios(notations, doneCallback, failCallback)` - Uploads audio files + +**Internal State:** +- `uploading: boolean` - Prevents concurrent uploads +- `lessonId: string | null` - Current lesson context +- `sessionId: string | null` - Current session context + +**Integration:** +- Listens to `AttachmentActions` (Reflux action dispatcher) +- Listens to `AppStore` for app initialization +- Uses `context.JK.Rest()` for API calls +- Uses `@app.layout.showDialog()` for upload progress dialog + +--- + +## 2. Upload Flow + +### Step-by-Step Breakdown (Notation Upload) + +**1. User Initiates Upload** +```javascript +// Translated from CoffeeScript +onStartAttachNotation(lessonId, sessionId = null) { + if (this.uploading) { + logger.warn("rejecting onStartAttachNotation attempt as currently busy"); + return; + } + this.lessonId = lessonId; + this.sessionId = sessionId; + + logger.debug("notation upload started for lesson: " + lessonId); + this.triggerNotation(); // Clicks hidden file input + this.changed(); // Triggers state update +} +``` + +**2. Hidden File Input Triggered** +```javascript +triggerNotation() { + if (!this.attachNotationBtn) { + this.attachNotationBtn = $('input.attachment-notation').eq(0); + } + console.log("@attachNotationBtn", this.attachNotationBtn); + this.attachNotationBtn.trigger('click'); +} +``` + +**3. Files Selected → onUploadNotations Called** +```javascript +onUploadNotations(notations, doneCallback, failCallback) { + logger.debug("beginning upload of notations", notations); + this.uploading = true; + this.changed(); + + // Client-side validation + const formData = new FormData(); + let maxExceeded = false; + + $.each(notations, (i, file) => { + const max = 10 * 1024 * 1024; // 10 MB + if (file.size > max) { + maxExceeded = true; + return false; // Break loop + } + formData.append('files[]', file); + }); + + if (maxExceeded) { + this.app.notify({ + title: "Maximum Music Notation Size Exceeded", + text: "You can only upload files up to 10 megabytes in size." + }); + failCallback(); + this.uploading = false; + this.changed(); + return; + } + + // Add context parameters + if (this.lessonId) { + formData.append('lesson_session_id', this.lessonId); + } else if (this.sessionId) { + formData.append('session_id', this.sessionId); + } + + formData.append('attachment_type', 'notation'); + + // Show progress dialog + this.app.layout.showDialog('music-notation-upload-dialog'); + + // Upload via REST API + rest.uploadMusicNotations(formData) + .done((response) => this.doneUploadingNotations(notations, response, doneCallback, failCallback)) + .fail((jqXHR) => this.failUploadingNotations(jqXHR, failCallback)); +} +``` + +**4. REST API Call (from jam_rest.js)** +```javascript +function uploadMusicNotations(formData) { + return $.ajax({ + type: "POST", + processData: false, // Don't convert FormData to string + contentType: false, // Don't set Content-Type (browser adds multipart boundary) + dataType: "json", + cache: false, + url: "/api/music_notations", + data: formData + }); +} +``` + +**5. Success Handler** +```javascript +doneUploadingNotations(notations, response, doneCallback, failCallback) { + this.uploading = false; + this.changed(); + + const error_files = []; + $.each(response, (i, music_notation) => { + if (music_notation.errors) { + error_files.push(notations[i].name); + } + }); + + if (error_files.length > 0) { + failCallback(); + this.app.notifyAlert("Failed to upload notations.", error_files.join(', ')); + } else { + doneCallback(); + } +} +``` + +**6. Error Handler** +```javascript +failUploadingNotations(jqXHR, failCallback) { + this.uploading = false; + this.changed(); + + if (jqXHR.status == 413) { + // File too large (server-side check) + this.app.notify({ + title: "Maximum Music Notation Size Exceeded", + text: "You can only upload files up to 10 megabytes in size." + }); + } else { + this.app.notifyServerError(jqXHR, "Unable to upload music notations"); + } +} +``` + +--- + +## 3. Client-Side Validation + +### File Size Check + +**Limit:** 10 MB (10 × 1024 × 1024 bytes) + +**Implementation:** +```javascript +const max = 10 * 1024 * 1024; +if (file.size > max) { + maxExceeded = true; + return false; +} +``` + +**Error Message:** +``` +Title: "Maximum Music Notation Size Exceeded" +Text: "You can only upload files up to 10 megabytes in size." +``` + +**When shown:** +- Immediately after file selection, before upload starts +- If server returns 413 (Payload Too Large) + +### File Type Validation + +**Note:** The legacy AttachmentStore does NOT perform file type validation on the client side. File type filtering happens at the file input level via HTML `accept` attribute, and server-side validation happens in the `MusicNotationUploader` whitelist. + +**Server-side whitelist (for reference):** +```ruby +def extension_white_list + %w(pdf png jpg jpeg gif xml mxl txt wav flac ogg aiff aifc au) +end +``` + +--- + +## 4. Attachment Type Detection + +The legacy implementation uses **explicit attachment type** passed as a parameter, not automatic detection based on file extension. + +**Two Upload Methods:** +1. `onUploadNotations()` → `attachment_type: 'notation'` +2. `onUploadAudios()` → `attachment_type: 'audio'` + +**How it works:** +```javascript +// For notation files +formData.append('attachment_type', 'notation'); + +// For audio files +formData.append('attachment_type', 'audio'); +``` + +**File Type to Attachment Type Mapping:** + +| File Extensions | Attachment Type | Used For | +|----------------|----------------|----------| +| `.pdf, .png, .jpg, .jpeg, .gif, .xml, .mxl, .txt` | `notation` | Music notation, sheet music, images, text | +| `.wav, .flac, .ogg, .aiff, .aifc, .au` | `audio` | Audio files | + +**Note:** The mapping is implicit based on which button the user clicks (Attach Notation vs Attach Audio), not programmatically determined from file extension. + +--- + +## 5. Integration Points + +### Integration with ChatStore + +The AttachmentStore does NOT directly trigger chat messages. Instead: + +1. **Upload completes** → Returns array of created `MusicNotation` objects +2. **Backend automatically creates ChatMessage** with attachment reference +3. **Backend broadcasts via WebSocket** → ChatMessage with attachment metadata +4. **ChatStore receives WebSocket message** → Displays in chat UI + +**Key insight:** The attachment upload is decoupled from chat message creation. The backend handles the integration. + +### Event Emission Pattern + +**Reflux trigger pattern:** +```javascript +changed() { + this.trigger({ + lessonId: this.lessonId, + uploading: this.uploading + }); +} +``` + +**When triggered:** +- On `onStartAttachNotation()` / `onStartAttachAudio()` - Sets `uploading: true` +- On `doneUploadingNotations()` / `failUploadingNotations()` - Sets `uploading: false` + +**UI components consume via:** +```javascript +// In React component +mixins: [Reflux.connect(AttachmentStore, 'attachmentState')] + +// Component can access this.state.attachmentState.uploading +``` + +### Dialog System Integration + +**Upload Progress Dialog:** +```javascript +this.app.layout.showDialog('music-notation-upload-dialog'); +``` + +**Notification System:** +```javascript +// Success/Info notification +this.app.notify({ + title: "Title", + text: "Message" +}); + +// Error notification +this.app.notifyAlert("Error title", "Error details"); + +// Server error handler +this.app.notifyServerError(jqXHR, "Context message"); +``` + +--- + +## 6. FormData Construction + +**Critical Pattern for React Port:** + +```javascript +// Create FormData instance +const formData = new FormData(); + +// Append files (supports multiple) +formData.append('files[]', file); // Array notation for multiple files + +// Append session context (one of these) +formData.append('lesson_session_id', lessonId); // For lesson chat +formData.append('session_id', sessionId); // For session chat + +// Append attachment type +formData.append('attachment_type', 'notation'); // or 'audio' + +// AJAX configuration +$.ajax({ + type: "POST", + processData: false, // CRITICAL: Don't convert FormData to string + contentType: false, // CRITICAL: Don't set Content-Type (browser adds multipart boundary) + dataType: "json", + url: "/api/music_notations", + data: formData +}); +``` + +**Why this matters for React port:** +- `processData: false` → When using fetch(), don't stringify FormData +- `contentType: false` → Don't manually set `Content-Type` header (browser auto-adds boundary) +- File array notation `files[]` → Required by Rails params parsing +- Session context → Only ONE of `lesson_session_id` or `session_id` should be set + +--- + +## 7. Audio Upload Flow + +The audio upload flow is nearly identical to notation upload, with these differences: + +**Method:** `onUploadAudios()` instead of `onUploadNotations()` + +**attachment_type:** `'audio'` instead of `'notation'` + +**Error messages:** +- Title: "Maximum Music Audio Size Exceeded" +- Failure: "Failed to upload audio files." + +**Code Example:** +```javascript +onUploadAudios(notations, doneCallback, failCallback) { + // ... same validation logic ... + + formData.append('attachment_type', 'audio'); // Only difference + + rest.uploadMusicNotations(formData) // Same endpoint! + .done((response) => this.doneUploadingAudios(notations, response, doneCallback, failCallback)) + .fail((jqXHR) => this.failUploadingAudios(jqXHR, failCallback)); +} +``` + +**Note:** Both notation and audio uploads use the same REST endpoint (`/api/music_notations`), differentiated only by the `attachment_type` parameter. + +--- + +## 8. Recording Attachment Flow + +**Different from file uploads:** Recording attachments link existing recordings to lessons/sessions, they don't upload files. + +```javascript +recordingsSelected(recordings) { + if (this.lessonId) { + const options = { id: this.lessonId, recordings: recordings }; + rest.attachRecordingToLesson(options) + .done((response) => this.attachedRecordingsToLesson(response)) + .fail((jqXHR) => this.attachedRecordingsFail(jqXHR)); + } else if (this.sessionId) { + const options = { id: this.sessionId, recordings: recordings }; + rest.attachRecordingToSession(options) + .done((response) => this.attachedRecordingsToSession(response)) + .fail((jqXHR) => this.attachedRecordingsFail(jqXHR)); + } +} +``` + +**Success messages:** +- Lesson: "Your recording has been associated with this lesson, and can be accessed from the Messages window for this lesson." +- Session: "Your recording has been associated with this session." + +**Note:** This flow is NOT in scope for v1.2 Session Attachments milestone. Recording attachments are handled separately from file uploads. + +--- + +## 9. Key Patterns for React Port + +### Pattern 1: Hidden File Input with Button Trigger +```javascript +// Legacy uses jQuery trigger +this.attachNotationBtn = $('input.attachment-notation').eq(0); +this.attachNotationBtn.trigger('click'); + +// React equivalent +const fileInputRef = useRef(null); +const handleAttachClick = () => { + fileInputRef.current?.click(); +}; + +return ( + <> + + { + if (e.target.files?.[0]) { + handleFileSelect(e.target.files[0]); + e.target.value = ''; // Reset for re-selection + } + }} + /> + +); +``` + +### Pattern 2: Upload State Management +```javascript +// Legacy Reflux store +this.uploading = true; +this.changed(); + +// React Redux equivalent +dispatch(setUploadingState({ uploading: true, error: null })); +``` + +### Pattern 3: Client-Side Validation Before Upload +```javascript +// Always validate BEFORE creating FormData +const MAX_SIZE = 10 * 1024 * 1024; +if (file.size > MAX_SIZE) { + dispatch(setUploadError('File exceeds 10 MB limit')); + return; +} + +// Then proceed with FormData construction +``` + +### Pattern 4: FormData with fetch() +```javascript +// Modern fetch equivalent +const formData = new FormData(); +formData.append('files[]', file); +formData.append('session_id', sessionId); +formData.append('attachment_type', 'notation'); + +const response = await fetch('/api/music_notations', { + method: 'POST', + credentials: 'include', // Important for session cookies + body: formData // Don't set Content-Type! +}); + +if (!response.ok) { + if (response.status === 413) { + throw new Error('File too large - maximum 10 MB'); + } + throw new Error('Upload failed'); +} + +return response.json(); +``` + +--- + +## 10. Differences Between Legacy and Modern Implementation + +| Aspect | Legacy (CoffeeScript) | Modern (React/Redux) | +|--------|----------------------|---------------------| +| Store pattern | Reflux with events | Redux Toolkit with slices | +| AJAX library | jQuery $.ajax | fetch() API | +| State updates | `this.changed()` triggers listeners | Redux actions with reducers | +| File input | jQuery trigger on hidden input | useRef + ref.current.click() | +| Context passing | Store instance variables | Redux state or component props | +| Callbacks | done/fail callbacks | async/await with try/catch | +| Error handling | jqXHR status checks | response.ok and response.status | +| Multiple files | Supported (files[] array) | Implement single file first, extend later | +| Dialog system | `@app.layout.showDialog()` | Custom React component or state flag | + +--- + +## 11. Implementation Checklist for React Port + +Based on the legacy implementation, the React port should include: + +- [ ] Redux state for upload status (`uploading`, `progress`, `error`) +- [ ] Hidden file input with ref-based trigger +- [ ] Client-side file size validation (10 MB) +- [ ] Client-side file type validation (match requirements list) +- [ ] FormData construction with `files[]`, `session_id`, `attachment_type` +- [ ] fetch() upload with `credentials: include`, no Content-Type header +- [ ] Error handling for 413 (file too large) +- [ ] Error handling for 422 (validation errors) +- [ ] Success/error user feedback (toast notifications) +- [ ] Disable UI during upload (prevent concurrent uploads) +- [ ] Reset file input after selection (allow re-selection) +- [ ] Integration with sessionChatSlice for attachment state +- [ ] WebSocket message handling for attachment metadata + +--- + +## Summary + +The legacy AttachmentStore provides a proven reference implementation for file uploads. Key takeaways: + +1. **FormData is critical:** `processData: false` and `contentType: false` equivalent in fetch() +2. **Client-side validation prevents wasted uploads:** Check size before FormData construction +3. **Single upload state flag:** Prevents concurrent uploads with simple boolean +4. **Backend creates chat messages:** Don't manually create chat entries after upload +5. **Error handling by status code:** 413 gets special user-friendly message +6. **Hidden file input pattern:** Standard approach, works reliably +7. **Same endpoint for both types:** Differentiated by `attachment_type` parameter + +**Next steps:** Refer to ATTACHMENT_API.md for backend contract details and response formats.