# 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 (
      <div className="chat-message attachment-message">
        <div className="message-header">
          <span className="sender-name">{message.senderName}</span>
          <span className="timestamp">{formatTimestamp(message.createdAt)}</span>
        </div>
        <div className="attachment-content">
          <span className="attachment-icon">
            {message.attachmentType === 'notation' ? '📄' : '🎵'}
          </span>
          <a
            className="attachment-link"
            onClick={() => handleAttachmentClick(message.attachmentId)}
          >
            {message.attachmentName}
          </a>
        </div>
      </div>
    );
  }

  // Regular text message
  return (
    <div className="chat-message">
      {/* ... regular message display ... */}
    </div>
  );
};
```

### 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 (
    <div className="attachment-button-container">
      <button
        onClick={() => fileInputRef.current?.click()}
        disabled={uploading}
        className="attach-button"
      >
        {uploading ? 'Uploading...' : 'Attach File'}
      </button>
      <input
        ref={fileInputRef}
        type="file"
        style={{ display: 'none' }}
        accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.mp3,.wav"
        onChange={handleFileSelect}
      />
      {error && <div className="error-message">{error}</div>}
    </div>
  );
};
```

### 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 (
    <a
      className="attachment-link"
      onClick={handleClick}
      style={{ cursor: downloading ? 'wait' : 'pointer' }}
    >
      {attachmentType === 'notation' ? '📄' : '🎵'} {attachmentName}
    </a>
  );
};
```

---

## 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
