masina-dock/ATTACHMENT_API.md
copilot-swe-agent[bot] 90143f7dc0 Add attachment deletion route and automatic cleanup on record deletion
Co-authored-by: aiulian25 <17886483+aiulian25@users.noreply.github.com>
2025-11-12 21:44:27 +00:00

11 KiB

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:

{
  "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:

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:

<a href="/api/attachments/download?path=attachments/20231112153045_a1b2c3d4_receipt.pdf">
  Download Receipt
</a>

Delete Attachment

Delete an attachment file.

Endpoint: DELETE /api/attachments/delete

Authentication: Required

Query Parameters:

  • path (required): Relative path to the attachment (e.g., attachments/filename.pdf)

Response:

{
  "message": "Attachment deleted successfully"
}

Error Responses:

  • 400: No file path provided
  • 403: Invalid file path (security violation)
  • 404: File not found
  • 500: Server error during deletion

Example:

await fetch(`/api/attachments/delete?path=${encodeURIComponent(filePath)}`, {
  method: 'DELETE',
  credentials: 'include'
});

Note: When deleting a fuel record, service record, or recurring expense, the associated attachment is automatically deleted.


Data Models

FuelRecord

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

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

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:

// 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:

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:

// 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:

// 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:

<input type="file" 
       id="fuel-attachment" 
       name="attachment" 
       accept="image/*,.pdf,.txt" 
       capture="environment">

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:

# 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

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

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');
}