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 ( +