diff --git a/.planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_API.md b/.planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_API.md new file mode 100644 index 000000000..12c89e3cb --- /dev/null +++ b/.planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_API.md @@ -0,0 +1,1119 @@ +# Backend API Contract - Music Notation Attachments + +**Source:** Rails backend (`api_music_notations_controller.rb`, `music_notation.rb`) +**Purpose:** Complete API contract documentation for React implementation +**Date:** 2026-02-02 + +--- + +## Overview + +The `/api/music_notations` endpoint provides file attachment capabilities for music sessions and lessons. It handles multipart file uploads, stores files to S3 via CarrierWave, generates signed download URLs, and automatically creates chat messages with attachment references. The API supports both notation files (PDF, images, XML) and audio files (WAV, FLAC, OGG, etc.). + +**Key features:** +- Multipart/form-data file uploads +- S3 storage with CarrierWave +- Signed URL generation for secure downloads (120-second expiration) +- Automatic ChatMessage creation with attachment metadata +- WebSocket broadcast after upload +- Permission checks for download/delete + +--- + +## 1. Endpoint Overview + +| Endpoint | Method | Purpose | Auth Required | Response | +|----------|--------|---------|---------------|----------| +| `/api/music_notations` | POST | Upload file(s) | Yes | 201 Created | +| `/api/music_notations/:id` | GET | Get signed download URL | Yes | 200 OK | +| `/api/music_notations/:id` | DELETE | Delete attachment | Yes | 204 No Content | + +**Base URL:** `https://www.jamkazam.com` (production) or `http://www.jamkazam.local:3000` (development) + +**Authentication:** All endpoints require session-based authentication via `remember_token` cookie. + +--- + +## 2. POST /api/music_notations - Upload Files + +### Request Format + +**Method:** POST + +**Content-Type:** `multipart/form-data` (set automatically by browser when using FormData) + +**Required Fields:** + +| Field | Type | Description | Example | +|-------|------|-------------|---------| +| `files[]` | File | File(s) to upload (array notation) | `files[]: document.pdf` | +| `session_id` | String | Music session ID (for session chat) | `"abc123"` | +| `attachment_type` | String | Type of attachment: `'notation'` or `'audio'` | `"notation"` | + +**Alternative Session Context:** + +| Field | Type | Description | Example | +|-------|------|-------------|---------| +| `lesson_session_id` | String | Lesson session ID (for lesson chat) | `"xyz789"` | + +**Note:** Either `session_id` OR `lesson_session_id` must be provided, not both. For v1.2 Session Attachments, use `session_id`. + +### Backend Implementation (api_music_notations_controller.rb) + +```ruby +def create + @music_notations = [] + + lesson_session = LessonSession.find_by_id(params[:lesson_session_id]) + + if lesson_session + session = lesson_session.music_session + else + session = MusicSession.find(params[:session_id]) + end + + params[:files].each do |file| + music_notation = MusicNotation.create(session.id, params[:attachment_type], file, current_user) + @music_notations.push music_notation + + if !music_notation.errors.any? + # Automatically create ChatMessage with attachment reference + if params[:attachment_type] == MusicNotation::TYPE_NOTATION + purpose = "Notation File" + else + purpose = "Audio File" + end + + if lesson_session + msg = ChatMessage.create(me, nil, '', ChatMessage::CHANNEL_LESSON, nil, other, lesson_session, purpose, music_notation) + else + if session.active_music_session + msg = ChatMessage.create(me, session.active_music_session, '', ChatMessage::CHANNEL_SESSION, nil, nil, nil, purpose, music_notation) + end + end + end + end + + respond_with @music_notations, responder: ApiResponder, :status => 201 +end +``` + +### Response (201 Created) + +**Content-Type:** `application/json` + +**Body:** Array of created MusicNotation objects + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "file_name": "sonata-no-14.pdf", + "file_url": "/api/music_notations/550e8400-e29b-41d4-a716-446655440000", + "size": 2458624, + "attachment_type": "notation", + "music_session_id": "abc123", + "user_id": "user789", + "created_at": "2026-02-02T18:30:00.000Z" + } +] +``` + +**Response Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Unique identifier for the attachment | +| `file_name` | String | Original filename from upload | +| `file_url` | String | API endpoint path for download (not S3 URL) | +| `size` | Integer | File size in bytes | +| `attachment_type` | String | `'notation'` or `'audio'` | +| `music_session_id` | UUID | Associated session ID | +| `user_id` | UUID | Uploader's user ID | +| `created_at` | ISO8601 | Upload timestamp | + +**Note on file_url:** This is NOT the S3 download URL. It's the API path. To get the actual download URL, make a GET request to this path (see section 3). + +### Error Response (413 Payload Too Large) + +**When:** File exceeds server-side size limit + +``` +HTTP/1.1 413 Payload Too Large +``` + +**Handling:** Check `response.status === 413` and show user-friendly message: "You can only upload files up to 10 megabytes in size." + +### Error Response (422 Unprocessable Entity) + +**When:** Validation errors (invalid type, missing required fields, disallowed file extension) + +```json +{ + "errors": { + "attachment_type": ["is not included in the list"], + "file_url": ["File extension not allowed"], + "size": ["can't be blank"] + } +} +``` + +**Handling:** Display validation errors to user with field-specific messages. + +### Error Response (404 Not Found) + +**When:** Invalid `session_id` or `lesson_session_id` + +``` +HTTP/1.1 404 Not Found +``` + +**Handling:** Show error message: "Session not found or no longer available." + +### Partial Success Response + +**When:** Some files upload successfully, others have validation errors + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "file_name": "valid-file.pdf", + "file_url": "/api/music_notations/550e8400-e29b-41d4-a716-446655440000", + "size": 1024000 + }, + { + "errors": { + "file_url": ["File extension not allowed"] + } + } +] +``` + +**Handling (from legacy code):** +```javascript +const error_files = []; +response.forEach((music_notation, i) => { + if (music_notation.errors) { + error_files.push(files[i].name); + } +}); + +if (error_files.length > 0) { + alert("Failed to upload files: " + error_files.join(', ')); +} +``` + +### curl Example + +```bash +curl -X POST \ + -H "Cookie: remember_token=YOUR_SESSION_TOKEN" \ + -F "files[]=@/path/to/document.pdf" \ + -F "session_id=abc123" \ + -F "attachment_type=notation" \ + http://www.jamkazam.local:3000/api/music_notations +``` + +**Multi-file upload:** +```bash +curl -X POST \ + -H "Cookie: remember_token=YOUR_SESSION_TOKEN" \ + -F "files[]=@/path/to/document1.pdf" \ + -F "files[]=@/path/to/document2.pdf" \ + -F "session_id=abc123" \ + -F "attachment_type=notation" \ + http://www.jamkazam.local:3000/api/music_notations +``` + +--- + +## 3. GET /api/music_notations/:id - Get Download URL + +### Request Format + +**Method:** GET + +**Path Parameter:** +- `:id` - The MusicNotation UUID from upload response + +**Query Parameters:** +- `target` (optional) - If `"_blank"`, redirects to S3 URL instead of returning JSON + +### Backend Implementation + +```ruby +def download + @music_notation = MusicNotation.find(params[:id]) + + unless @music_notation.music_session.can_join?(current_user, true) + render :text => "Permission denied", status: 403 + return + end + + if '_blank'==params[:target] + redirect_to @music_notation.sign_url + else + render :json => {url: @music_notation.sign_url} + end +end +``` + +**Permission Check:** Verifies current user can join the music session associated with the attachment. + +**Signed URL Generation (from MusicNotation model):** +```ruby +def sign_url(expiration_time = 120) + s3_manager.sign_url(self[:file_url], {:expires => expiration_time, :secure => true}) +end +``` + +**URL Expiration:** 120 seconds (2 minutes) by default. After expiration, URL becomes invalid and must be regenerated. + +### Response (200 OK) + +**Content-Type:** `application/json` + +```json +{ + "url": "https://s3.amazonaws.com/jk-music-notations/music_session_notations/20260202183000/user789/sonata-no-14.pdf?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Expires=1738511520&Signature=abcdef123456..." +} +``` + +**Response Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `url` | String | Signed S3 URL valid for 120 seconds | + +**S3 URL Structure:** +``` +https://s3.amazonaws.com/{bucket}/{path}?AWSAccessKeyId={key}&Expires={timestamp}&Signature={sig} +``` + +**Path Format (from MusicNotation.construct_filename):** +``` +music_session_notations/{YYYYMMDDHHMMSS}/{user_id}/{original_filename} +``` + +Example: `music_session_notations/20260202183000/user789/sonata-no-14.pdf` + +### Response (200 OK) - Redirect Mode + +**When:** `target=_blank` parameter provided + +**Behavior:** HTTP 302 redirect to S3 signed URL (browser automatically downloads/opens file) + +``` +HTTP/1.1 302 Found +Location: https://s3.amazonaws.com/jk-music-notations/music_session_notations/... +``` + +### Error Response (403 Forbidden) + +**When:** User doesn't have permission to access the session + +``` +HTTP/1.1 403 Forbidden +Permission denied +``` + +### Error Response (404 Not Found) + +**When:** Invalid attachment ID + +``` +HTTP/1.1 404 Not Found +``` + +### curl Example + +**JSON response:** +```bash +curl -X GET \ + -H "Cookie: remember_token=YOUR_SESSION_TOKEN" \ + http://www.jamkazam.local:3000/api/music_notations/550e8400-e29b-41d4-a716-446655440000 +``` + +**Redirect mode:** +```bash +curl -L -X GET \ + -H "Cookie: remember_token=YOUR_SESSION_TOKEN" \ + "http://www.jamkazam.local:3000/api/music_notations/550e8400-e29b-41d4-a716-446655440000?target=_blank" +``` + +### JavaScript Usage Pattern + +```javascript +// Fetch signed URL, then open in new tab +const downloadAttachment = async (attachmentId) => { + try { + const response = await fetch(`/api/music_notations/${attachmentId}`, { + credentials: 'include' + }); + + if (!response.ok) { + if (response.status === 403) { + throw new Error('You do not have permission to access this file'); + } + throw new Error('Failed to fetch download URL'); + } + + const { url } = await response.json(); + window.open(url, '_blank'); + } catch (error) { + console.error('Download failed:', error); + alert(error.message); + } +}; +``` + +--- + +## 4. DELETE /api/music_notations/:id - Delete Attachment + +### Request Format + +**Method:** DELETE + +**Path Parameter:** +- `:id` - The MusicNotation UUID to delete + +### Backend Implementation + +```ruby +def delete + @music_notation = MusicNotation.find(params[:id]) + + unless @music_notation.music_session.can_join?(current_user, true) + render :text => "Permission denied", status: 403 + return + end + + @music_notation.destroy + + render :json => {}, status: 204 +end +``` + +**Permission Check:** Verifies current user can join the music session associated with the attachment. + +**Cascade Behavior (from MusicNotation model):** +```ruby +before_destroy :delete_s3_files + +def delete_s3_files + s3_manager({:public => true}).delete(self[:file_url]) if self[:file_url] +end +``` + +**When attachment is deleted:** +1. Database record is marked as deleted +2. S3 file is deleted (before_destroy callback) +3. Associated ChatMessage remains (soft reference via attachment_id) + +### Response (204 No Content) + +**When:** Successful deletion + +``` +HTTP/1.1 204 No Content +``` + +**Body:** Empty (no content) + +### Error Response (403 Forbidden) + +**When:** User doesn't have permission to delete + +``` +HTTP/1.1 403 Forbidden +Permission denied +``` + +### Error Response (404 Not Found) + +**When:** Attachment not found or already deleted + +``` +HTTP/1.1 404 Not Found +``` + +### curl Example + +```bash +curl -X DELETE \ + -H "Cookie: remember_token=YOUR_SESSION_TOKEN" \ + http://www.jamkazam.local:3000/api/music_notations/550e8400-e29b-41d4-a716-446655440000 +``` + +### JavaScript Usage Pattern + +```javascript +const deleteAttachment = async (attachmentId) => { + try { + const response = await fetch(`/api/music_notations/${attachmentId}`, { + method: 'DELETE', + credentials: 'include' + }); + + if (!response.ok) { + if (response.status === 403) { + throw new Error('You do not have permission to delete this file'); + } + if (response.status === 404) { + throw new Error('File not found or already deleted'); + } + throw new Error('Failed to delete file'); + } + + // Success - 204 has no body + console.log('File deleted successfully'); + } catch (error) { + console.error('Delete failed:', error); + alert(error.message); + } +}; +``` + +**Note:** Delete is NOT required for v1.2 Session Attachments milestone. It's documented for completeness. + +--- + +## 5. File Validation Rules + +### Server-Side Extension Whitelist + +**Source:** `ruby/lib/jam_ruby/app/uploaders/music_notation_uploader.rb` + +```ruby +def extension_white_list + %w(pdf png jpg jpeg gif xml mxl txt wav flac ogg aiff aifc au) +end +``` + +**Allowed Extensions:** + +| Category | Extensions | Use Case | +|----------|-----------|----------| +| Document | `.pdf, .txt` | Sheet music PDFs, text notes | +| Image | `.png, .jpg, .jpeg, .gif` | Scanned sheet music, diagrams | +| Notation | `.xml, .mxl` | MusicXML notation files | +| Audio | `.wav, .flac, .ogg, .aiff, .aifc, .au` | Audio recordings, backing tracks | + +**Note:** `.mp3` is NOT in the server whitelist but IS in v1.2 requirements. This needs to be resolved: +- Option 1: Add `.mp3` to server whitelist +- Option 2: Restrict client-side to match server list +- **Recommended:** Add `.mp3` to server whitelist (one-line change) + +### Server-Side Size Limit + +**Location:** Web server configuration (Nginx/Apache) and Rails middleware + +**Limit:** 10 MB (10,485,760 bytes) + +**When exceeded:** HTTP 413 Payload Too Large response + +**Note:** Client-side validation should prevent uploads exceeding 10 MB before sending request. + +### Client-Side Validation (Recommended) + +**File Size Check:** +```javascript +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + +if (file.size > MAX_FILE_SIZE) { + alert('File too large. Maximum size is 10 MB.'); + return; +} +``` + +**File Type Check:** +```javascript +const ALLOWED_EXTENSIONS = [ + '.pdf', '.xml', '.mxl', '.txt', + '.png', '.jpg', '.jpeg', '.gif', + '.mp3', '.wav' // Per v1.2 requirements +]; + +const ext = '.' + file.name.split('.').pop().toLowerCase(); +if (!ALLOWED_EXTENSIONS.includes(ext)) { + alert(`File type ${ext} is not allowed.`); + return; +} +``` + +**Attachment Type Detection:** +```javascript +const AUDIO_EXTENSIONS = ['.mp3', '.wav']; + +const getAttachmentType = (fileName) => { + const ext = '.' + fileName.split('.').pop().toLowerCase(); + return AUDIO_EXTENSIONS.includes(ext) ? 'audio' : 'notation'; +}; +``` + +### Model Validation (music_notation.rb) + +```ruby +validates :attachment_type, :presence => true, inclusion: {in: ATTACHMENT_TYPES} +validates :size, :presence => true +``` + +**ATTACHMENT_TYPES constant:** +```ruby +TYPE_NOTATION = 'notation' +TYPE_AUDIO = 'audio' +ATTACHMENT_TYPES = [TYPE_NOTATION, TYPE_AUDIO] +``` + +**Validation errors trigger 422 response with error details.** + +--- + +## 6. ChatMessage Integration + +### Automatic Chat Message Creation + +When a file is uploaded successfully, the backend **automatically creates a ChatMessage** with attachment metadata. No separate API call is needed. + +**Backend Logic (from api_music_notations_controller.rb):** + +```ruby +if !music_notation.errors.any? + if params[:attachment_type] == MusicNotation::TYPE_NOTATION + purpose = "Notation File" + else + purpose = "Audio File" + end + + if lesson_session + msg = ChatMessage.create(me, nil, '', ChatMessage::CHANNEL_LESSON, nil, other, lesson_session, purpose, music_notation) + else + if session.active_music_session + msg = ChatMessage.create(me, session.active_music_session, '', ChatMessage::CHANNEL_SESSION, nil, nil, nil, purpose, music_notation) + end + end +end +``` + +### ChatMessage Fields Populated + +**From ChatMessage model:** + +| Field | Value | Description | +|-------|-------|-------------| +| `user_id` | Uploader's user ID | Who uploaded the file | +| `music_session_id` | Session ID | Which session the file belongs to | +| `message` | Empty string `''` | Attachment messages have no text | +| `channel` | `CHANNEL_SESSION` or `CHANNEL_LESSON` | Chat channel | +| `purpose` | `"Notation File"` or `"Audio File"` | Message type indicator | +| `attachment_id` | `music_notation.id` | Link to MusicNotation record | +| `attachment_type` | `'notation'` or `'audio'` | Type of attachment | +| `attachment_name` | `music_notation.file_name` | Original filename | + +**Purpose Values:** +- `"Notation File"` - When `attachment_type: 'notation'` +- `"Audio File"` - When `attachment_type: 'audio'` + +### WebSocket Broadcast + +After ChatMessage creation, the backend broadcasts via WebSocket to all session participants. + +**Protocol Buffer Message (CHAT_MESSAGE type):** + +```protobuf +message ChatMessage { + string msg_id = 1; // ChatMessage.id + string user_id = 2; // Uploader ID + string user_name = 3; // Uploader name + string message = 4; // Empty for attachments + string created_at = 5; // ISO8601 timestamp + string channel = 6; // 'session' or 'lesson' + string purpose = 7; // 'Notation File' or 'Audio File' + string attachment_id = 8; // MusicNotation.id + string attachment_type = 9; // 'notation' or 'audio' + string attachment_name = 10; // Original filename +} +``` + +**JavaScript WebSocket Handler (from Phase 7):** + +```javascript +const handleChatMessage = (payload) => { + const message = { + id: payload.msg_id, + senderId: payload.user_id, + senderName: payload.user_name, + text: payload.message, + createdAt: payload.created_at, + channel: payload.channel, + + // Attachment fields (may be undefined for regular messages) + purpose: payload.purpose, + attachmentId: payload.attachment_id, + attachmentType: payload.attachment_type, + attachmentName: payload.attachment_name + }; + + dispatch(addMessageFromWebSocket(message)); +}; +``` + +### Attachment Message Display Logic + +**In JKChatMessage component:** + +```javascript +const JKChatMessage = ({ message }) => { + // Check if message is an attachment + if (message.purpose === 'Notation File' || message.purpose === 'Audio File') { + return ( +
+
+ {message.senderName} + {formatTimestamp(message.createdAt)} +
+
+ + {message.attachmentType === 'notation' ? '📄' : '🎵'} + + handleAttachmentClick(message.attachmentId)} + > + {message.attachmentName} + +
+
+ ); + } + + // Regular text message + return ( +
+ {/* ... regular message display ... */} +
+ ); +}; +``` + +### Key Integration Points + +1. **Upload triggers chat message** - No manual chat message creation needed +2. **WebSocket delivers message** - Use existing `handleChatMessage` with attachment fields +3. **Display by purpose field** - Check `message.purpose` to detect attachment messages +4. **Download via API** - Click attachment → GET `/api/music_notations/:id` → open signed URL + +**Critical:** Do NOT try to manually create chat messages after upload. The backend handles this. Just wait for the WebSocket message to arrive. + +--- + +## 7. S3 Storage Details + +### CarrierWave Configuration + +**Uploader:** `MusicNotationUploader` (CarrierWave-based) + +**Storage:** AWS S3 + +**Configuration Source:** `ruby/lib/jam_ruby/app/uploaders/music_notation_uploader.rb` + +```ruby +def initialize(*) + super + JamRuby::UploaderConfiguration.set_aws_private_configuration(self) +end +``` + +**Bucket:** Configured in environment variables (different for dev/staging/production) + +**Access:** Private (not publicly accessible without signed URL) + +### File Path Structure + +**Path Format (from MusicNotation model):** + +```ruby +def self.construct_filename(notation) + "#{NOTATION_FILE_DIR}/#{notation.created_at.strftime('%Y%m%d%H%M%S')}/#{notation.user.id}/#{notation.file_name}" +end +``` + +**Components:** +- `NOTATION_FILE_DIR` = `"music_session_notations"` +- `created_at` timestamp = `YYYYMMDDHHMMSS` (e.g., `20260202183000`) +- `user.id` = Uploader's user ID (e.g., `user789`) +- `file_name` = Original filename (e.g., `sonata-no-14.pdf`) + +**Example S3 Path:** +``` +s3://jk-music-notations/music_session_notations/20260202183000/user789/sonata-no-14.pdf +``` + +### Signed URL Generation + +**Method:** AWS SDK `sign_url` with HMAC-SHA1 signature + +**Expiration:** 120 seconds (2 minutes) + +**Security:** URL includes AWS access key, expiration timestamp, and signature + +**Implementation (from MusicNotation model):** +```ruby +def sign_url(expiration_time = 120) + s3_manager.sign_url(self[:file_url], {:expires => expiration_time, :secure => true}) +end +``` + +**Generated URL Format:** +``` +https://s3.amazonaws.com/{bucket}/{path}?AWSAccessKeyId={key}&Expires={timestamp}&Signature={signature} +``` + +**After expiration:** +- URL returns HTTP 403 Forbidden +- Must call GET `/api/music_notations/:id` again to get new signed URL + +### File Deletion + +**When:** MusicNotation record is destroyed (via DELETE endpoint) + +**Callback:** `before_destroy :delete_s3_files` + +**Implementation:** +```ruby +def delete_s3_files + s3_manager({:public => true}).delete(self[:file_url]) if self[:file_url] +end +``` + +**Behavior:** +1. Database record deleted first +2. Then S3 file deleted via AWS SDK +3. If S3 deletion fails, error is logged but record still deleted + +--- + +## 8. Authentication & Authorization + +### Authentication + +**Method:** Session-based authentication via `remember_token` cookie + +**Filter:** `before_filter :api_signed_in_user` (all endpoints) + +**Cookie Domain:** `.jamkazam.com` (shared between `www` and `beta` subdomains) + +**When not authenticated:** +- HTTP 401 Unauthorized +- Redirect to sign-in page (for browser requests) +- JSON error response (for AJAX requests) + +### Authorization (Permission Checks) + +**Download Permission (GET /api/music_notations/:id):** +```ruby +unless @music_notation.music_session.can_join?(current_user, true) + render :text => "Permission denied", status: 403 + return +end +``` + +**Delete Permission (DELETE /api/music_notations/:id):** +```ruby +unless @music_notation.music_session.can_join?(current_user, true) + render :text => "Permission denied", status: 403 + return +end +``` + +**Permission Logic (MusicSession.can_join?):** +- User is session creator +- User is invited to session +- User is participant in active session +- User has lesson access (for lesson sessions) + +**Upload Permission (POST /api/music_notations):** +- Implicit: Must have valid `session_id` or `lesson_session_id` +- `MusicSession.find(params[:session_id])` raises 404 if not found +- No explicit permission check (assumes if you know the ID, you can attach) + +--- + +## 9. Error Handling Summary + +### HTTP Status Codes + +| Code | When | Response Body | User Message | +|------|------|---------------|--------------| +| 200 | Successful GET | JSON with signed URL | (auto-download or display) | +| 201 | Successful upload | Array of MusicNotation objects | "File uploaded successfully" | +| 204 | Successful delete | Empty | "File deleted successfully" | +| 401 | Not authenticated | Error message | "Please sign in to continue" | +| 403 | Permission denied | "Permission denied" | "You don't have access to this file" | +| 404 | Not found | Error message | "Session or file not found" | +| 413 | File too large | Empty | "File exceeds 10 MB limit" | +| 422 | Validation error | JSON with error details | "Invalid file type or missing required fields" | +| 500 | Server error | Error message | "An error occurred. Please try again." | + +### Client-Side Error Handling Pattern + +```javascript +const uploadAttachment = async (file, sessionId) => { + try { + const response = await fetch('/api/music_notations', { + method: 'POST', + credentials: 'include', + body: formData + }); + + if (!response.ok) { + // Handle specific error codes + if (response.status === 413) { + throw new Error('File too large - maximum 10 MB'); + } + if (response.status === 422) { + const errors = await response.json(); + throw new Error('Validation error: ' + JSON.stringify(errors)); + } + if (response.status === 404) { + throw new Error('Session not found'); + } + throw new Error('Upload failed'); + } + + return await response.json(); + } catch (error) { + console.error('Upload failed:', error); + throw error; + } +}; +``` + +--- + +## 10. Complete API Usage Example + +### Upload Flow (React Implementation) + +```javascript +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +const JKChatAttachButton = ({ sessionId }) => { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const handleFileSelect = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Reset file input for re-selection + e.target.value = ''; + + // Client-side validation + const MAX_SIZE = 10 * 1024 * 1024; + if (file.size > MAX_SIZE) { + setError('File too large. Maximum size is 10 MB.'); + return; + } + + const ALLOWED_TYPES = ['.pdf', '.xml', '.mxl', '.txt', '.png', '.jpg', '.jpeg', '.gif', '.mp3', '.wav']; + const ext = '.' + file.name.split('.').pop().toLowerCase(); + if (!ALLOWED_TYPES.includes(ext)) { + setError(`File type ${ext} is not allowed.`); + return; + } + + // Determine attachment type + const AUDIO_TYPES = ['.mp3', '.wav']; + const attachmentType = AUDIO_TYPES.includes(ext) ? 'audio' : 'notation'; + + // Build FormData + const formData = new FormData(); + formData.append('files[]', file); + formData.append('session_id', sessionId); + formData.append('attachment_type', attachmentType); + + // Upload + setUploading(true); + setError(null); + + try { + const baseUrl = process.env.REACT_APP_API_BASE_URL || ''; + const response = await fetch(`${baseUrl}/api/music_notations`, { + method: 'POST', + credentials: 'include', + body: formData + }); + + if (!response.ok) { + if (response.status === 413) { + throw new Error('File too large - maximum 10 MB'); + } + if (response.status === 422) { + const errors = await response.json(); + throw new Error('File validation failed'); + } + throw new Error('Upload failed'); + } + + const result = await response.json(); + console.log('Upload successful:', result); + + // Success! Backend will send WebSocket message with attachment + // Don't manually add to chat - wait for WebSocket + + } catch (err) { + console.error('Upload error:', err); + setError(err.message); + } finally { + setUploading(false); + } + }; + + return ( +
+ + + {error &&
{error}
} +
+ ); +}; +``` + +### Download Flow (React Implementation) + +```javascript +const JKChatAttachmentLink = ({ attachmentId, attachmentName, attachmentType }) => { + const [downloading, setDownloading] = useState(false); + + const handleClick = async () => { + setDownloading(true); + + try { + const baseUrl = process.env.REACT_APP_API_BASE_URL || ''; + const response = await fetch(`${baseUrl}/api/music_notations/${attachmentId}`, { + credentials: 'include' + }); + + if (!response.ok) { + if (response.status === 403) { + throw new Error('You do not have permission to access this file'); + } + throw new Error('Failed to fetch download URL'); + } + + const { url } = await response.json(); + window.open(url, '_blank'); + + } catch (err) { + console.error('Download error:', err); + alert(err.message); + } finally { + setDownloading(false); + } + }; + + return ( + + {attachmentType === 'notation' ? '📄' : '🎵'} {attachmentName} + + ); +}; +``` + +--- + +## 11. Database Schema Reference + +### MusicNotation Table + +**Table Name:** `music_notations` + +**Columns:** + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | UUID | NOT NULL | Primary key | +| `music_session_id` | UUID | NOT NULL | Foreign key to music_sessions | +| `user_id` | UUID | NOT NULL | Foreign key to users (uploader) | +| `file_url` | VARCHAR | NOT NULL | S3 path (not full URL) | +| `file_name` | VARCHAR | NOT NULL | Original filename | +| `size` | INTEGER | NOT NULL | File size in bytes | +| `attachment_type` | VARCHAR | NOT NULL | 'notation' or 'audio' | +| `created_at` | TIMESTAMP | NOT NULL | Upload timestamp | +| `updated_at` | TIMESTAMP | NOT NULL | Last update timestamp | + +**Relationships:** +- `belongs_to :music_session` +- `belongs_to :user` +- `has_many :chat_messages` (via `attachment_id` foreign key) + +### ChatMessage Fields (Attachment-Related) + +**Relevant Columns:** + +| Column | Type | Description | +|--------|------|-------------| +| `purpose` | VARCHAR | 'Notation File', 'Audio File', or NULL | +| `attachment_id` | UUID | Foreign key to music_notations | +| `attachment_type` | VARCHAR | 'notation' or 'audio' | +| `attachment_name` | VARCHAR | Original filename | + +**Query Example (Get attachment messages for session):** +```sql +SELECT * FROM chat_messages +WHERE music_session_id = 'abc123' + AND purpose IN ('Notation File', 'Audio File') + AND attachment_id IS NOT NULL +ORDER BY created_at ASC; +``` + +--- + +## Summary + +The `/api/music_notations` API provides a complete file attachment solution: + +1. **Upload:** POST with multipart FormData → Returns 201 with MusicNotation array +2. **Download:** GET by ID → Returns signed S3 URL (120-second expiration) +3. **Delete:** DELETE by ID → Returns 204 (removes S3 file) + +**Key Patterns:** +- Always use `credentials: include` for session auth +- Never set `Content-Type` header manually for FormData uploads +- Validate client-side before upload (10 MB, extension whitelist) +- Handle 413 specially (file too large message) +- Wait for WebSocket message after upload (don't manually add to chat) +- Generate new signed URL for each download (URLs expire in 2 minutes) + +**Integration with Chat:** +- Backend automatically creates ChatMessage with `purpose`, `attachment_id`, `attachment_type`, `attachment_name` +- WebSocket broadcasts message to all participants +- React displays attachment using `message.purpose` to detect attachment type +- Click attachment → GET signed URL → open in new tab + +**Next Steps:** +- Refer to ATTACHMENT_LEGACY.md for client-side implementation patterns +- Use existing sessionChatSlice from Phase 7-11 for state management +- Extend JKChatMessage component to handle attachment display +- Add attach button to JKChatComposer component