394 lines
10 KiB
Markdown
394 lines
10 KiB
Markdown
# 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
|
|
<a href="/api/attachments/download?path=attachments/20231112153045_a1b2c3d4_receipt.pdf">
|
|
Download Receipt
|
|
</a>
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
<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:
|
|
|
|
```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');
|
|
}
|
|
```
|