# Attachment API Documentation ## Overview The Masina-Dock application supports file attachments for fuel records, service records, and recurring expenses (taxes). This document describes the API endpoints, data models, and usage patterns for working with attachments. ## Security - All attachment operations require authentication via `@login_required` decorator - Users can only access their own attachments through vehicle ownership validation - File paths are validated to prevent directory traversal attacks - Uploaded files are stored in `/app/uploads/attachments/` with secure filenames ## API Endpoints ### Upload Attachment Upload a file attachment. **Endpoint:** `POST /api/upload/attachment` **Authentication:** Required **Request:** - Content-Type: `multipart/form-data` - Body parameter: `attachment` (file) **Allowed File Types:** - PDF: `.pdf` - Images: `.png`, `.jpg`, `.jpeg` - Documents: `.txt`, `.doc`, `.docx` - Spreadsheets: `.xls`, `.xlsx`, `.csv` - OpenDocument: `.odt`, `.ods` **Response:** ```json { "file_path": "attachments/20231112153045_a1b2c3d4_receipt.pdf" } ``` **Error Responses:** - 400: No attachment provided / No file selected / Invalid file type - 500: Server error during upload **Example:** ```javascript const formData = new FormData(); formData.append('attachment', fileInput.files[0]); const response = await fetch('/api/upload/attachment', { method: 'POST', credentials: 'include', body: formData }); const data = await response.json(); const filePath = data.file_path; ``` --- ### Download Attachment Download or view an attachment. **Endpoint:** `GET /api/attachments/download` **Authentication:** Required **Query Parameters:** - `path` (required): Relative path to the attachment (e.g., `attachments/filename.pdf`) **Response:** - File content with appropriate Content-Type header - Downloads as attachment **Error Responses:** - 400: No file path provided - 403: Invalid file path (security violation) - 404: File not found **Example:** ```html Download Receipt ``` --- ## Data Models ### FuelRecord ```python class FuelRecord(db.Model): id = db.Column(db.Integer, primary_key=True) vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False) date = db.Column(db.Date, nullable=False) odometer = db.Column(db.Integer, nullable=False) fuel_amount = db.Column(db.Float, nullable=False) cost = db.Column(db.Float, nullable=False) unit_cost = db.Column(db.Float) distance = db.Column(db.Integer) fuel_economy = db.Column(db.Float) unit = db.Column(db.String(20), default='MPG') notes = db.Column(db.Text) document_path = db.Column(db.String(255)) # NEW: Stores attachment path created_at = db.Column(db.DateTime, default=datetime.utcnow) ``` ### RecurringExpense ```python class RecurringExpense(db.Model): id = db.Column(db.Integer, primary_key=True) vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False) expense_type = db.Column(db.String(50), nullable=False) description = db.Column(db.String(200), nullable=False) amount = db.Column(db.Float, nullable=False) frequency = db.Column(db.String(20), nullable=False) start_date = db.Column(db.Date, nullable=False) next_due_date = db.Column(db.Date, nullable=False) is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) notes = db.Column(db.Text) document_path = db.Column(db.String(255)) # NEW: Stores attachment path ``` ### ServiceRecord ```python class ServiceRecord(db.Model): id = db.Column(db.Integer, primary_key=True) vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False) date = db.Column(db.Date, nullable=False) odometer = db.Column(db.Integer, nullable=False) description = db.Column(db.String(200), nullable=False) cost = db.Column(db.Float, default=0.0) notes = db.Column(db.Text) category = db.Column(db.String(50)) document_path = db.Column(db.String(255)) # EXISTING: Already supported created_at = db.Column(db.DateTime, default=datetime.utcnow) ``` --- ## Using Attachments with Records ### Fuel Records **Create with Attachment:** ```javascript // 1. Upload attachment first const formData = new FormData(); formData.append('attachment', fileInput.files[0]); const uploadResponse = await fetch('/api/upload/attachment', { method: 'POST', credentials: 'include', body: formData }); const { file_path } = await uploadResponse.json(); // 2. Create fuel record with attachment path await fetch(`/api/vehicles/${vehicleId}/fuel-records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ date: '2023-11-12', odometer: 45000, fuel_amount: 12.5, cost: 45.00, unit: 'MPG', notes: 'Regular fill-up', document_path: file_path // Attach the uploaded file }) }); ``` **Retrieve with Attachment:** ```javascript const records = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, { credentials: 'include' }).then(r => r.json()); // Each record includes document_path records.forEach(record => { if (record.document_path) { console.log(`Attachment: ${record.document_path}`); } }); ``` ### Recurring Expenses (Taxes) **Create with Attachment:** ```javascript // 1. Upload attachment const formData = new FormData(); formData.append('attachment', fileInput.files[0]); const uploadResponse = await fetch('/api/upload/attachment', { method: 'POST', credentials: 'include', body: formData }); const { file_path } = await uploadResponse.json(); // 2. Create recurring expense with attachment await fetch(`/api/vehicles/${vehicleId}/recurring-expenses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ expense_type: 'Insurance', description: 'Car Insurance', amount: 1200.00, frequency: 'yearly', start_date: '2023-11-12', notes: 'Annual premium', document_path: file_path // Attach the policy document }) }); ``` ### Service Records **Create with Attachment:** ```javascript // Same pattern as fuel records const formData = new FormData(); formData.append('attachment', fileInput.files[0]); const uploadResponse = await fetch('/api/upload/attachment', { method: 'POST', credentials: 'include', body: formData }); const { file_path } = await uploadResponse.json(); await fetch(`/api/vehicles/${vehicleId}/service-records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ date: '2023-11-12', odometer: 45000, description: 'Oil Change', category: 'Maintenance', cost: 45.00, notes: 'Synthetic oil', document_path: file_path // Attach the invoice }) }); ``` --- ## Mobile Camera Support All attachment input fields support camera capture on mobile devices via the `capture="environment"` attribute: ```html ``` On mobile devices, this will prompt the user to: 1. Take a photo with the camera, OR 2. Select a file from the device The `accept="image/*"` attribute allows any image format, while specific formats like `.pdf` and `.txt` are also accepted. --- ## Migration To add attachment support to an existing database, run the migration script: ```bash # Inside Docker container python /app/backend/migrate_attachments.py # Or locally cd backend python migrate_attachments.py ``` This will add the `document_path` column to: - `fuel_record` table - `recurring_expense` table --- ## File Storage **Location:** `/app/uploads/attachments/` **Filename Format:** `{timestamp}_{random}_{original_filename}` - Example: `20231112153045_a1b2c3d4_receipt.pdf` **Permissions:** Files are saved with `0o644` permissions (read for all, write for owner) **Directory Creation:** The attachments directory is created automatically if it doesn't exist --- ## Best Practices 1. **Always upload the file first**, then reference it in the record 2. **Handle upload failures gracefully** - allow users to save records without attachments 3. **Validate file types** on both client and server side 4. **Use relative paths** - store only `attachments/filename.ext`, not absolute paths 5. **Check file existence** before displaying download links 6. **Implement cleanup** - consider removing orphaned attachments when records are deleted --- ## Example: Complete Flow ```javascript async function addFuelRecordWithAttachment(vehicleId, recordData, file) { let documentPath = null; // Step 1: Upload attachment if present if (file) { try { const formData = new FormData(); formData.append('attachment', file); const uploadResponse = await fetch('/api/upload/attachment', { method: 'POST', credentials: 'include', body: formData }); if (!uploadResponse.ok) { console.error('Attachment upload failed'); // Continue without attachment } else { const { file_path } = await uploadResponse.json(); documentPath = file_path; } } catch (error) { console.error('Attachment upload error:', error); // Continue without attachment } } // Step 2: Create fuel record const response = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ...recordData, document_path: documentPath }) }); if (!response.ok) { throw new Error('Failed to create fuel record'); } return await response.json(); } ``` --- ## Error Handling ```javascript try { const formData = new FormData(); formData.append('attachment', file); const response = await fetch('/api/upload/attachment', { method: 'POST', credentials: 'include', body: formData }); if (!response.ok) { const error = await response.json(); console.error('Upload failed:', error.error); // Handle specific error cases if (response.status === 400) { alert('Invalid file type or no file selected'); } else if (response.status === 500) { alert('Server error during upload'); } } } catch (error) { console.error('Network error:', error); alert('Failed to connect to server'); } ```