Files
honeyDueAPI/internal/models/document.go
T
Trey t 29c9014a33
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
feat(uploads): direct-to-B2 presigned uploads with content-length-range policy
Replaces the multipart-via-API path for image uploads with a three-step
direct-to-storage flow:

  1. Client POSTs /api/uploads/presign with content_length + content_type;
     server validates size (10 MB cap), mime allow-list per category, rate
     limit (50/hour/user via Redis sliding window), and concurrent unclaimed
     cap (10 in-flight per user). On success it persists a pending_uploads
     row, signs an S3 POST policy with content-length-range bound to the
     claimed length ±256 bytes, and returns the URL+fields.
  2. Client POSTs the bytes directly to B2 using the signed policy. B2
     enforces size, content-type, and key match before accepting.
  3. Client passes upload_ids[] to /api/task-completions/ or /api/documents/.
     Service HEADs each B2 object, verifies size matches expected_bytes
     within slack, marks pending_uploads claimed_at, and creates the
     associated TaskCompletionImage / DocumentImage rows.

Bytes never traverse our API server. The 1 MB Echo BodyLimit middleware
that was rejecting all task-completion image uploads becomes irrelevant
for this path. Existing multipart endpoints stay functional alongside,
soak-testing the new path before legacy removal.

Cleanup:
  - cmd/worker registers a new hourly cron (TypeUploadCleanup, "30 * * * *")
    that reaps pending_uploads where claimed_at IS NULL AND expires_at < NOW().
    Reaps both the B2 object and the row.
  - B2 bucket lifecycle rule on `uploads/` prefix (7 days hide → 1 day delete)
    documented in deploy-k3s/manifests/b2-lifecycle.md as a backstop.

Schema:
  - migrations/000002_pending_uploads.sql adds the table + partial index for
    cleanup + nullable pending_upload_id FKs on task_taskcompletionimage and
    task_documentimage.

Policy (single tier, no free/pro split):
  - 10 MB cap per upload
  - 50 presigns/hour/user
  - 10 concurrent unclaimed uploads/user
  - allow-list: jpeg/png/heic/heif/webp for image categories;
    + pdf for document_file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:36:42 -07:00

102 lines
4.3 KiB
Go

package models
import (
"time"
"github.com/shopspring/decimal"
)
// DocumentType represents the type of document
type DocumentType string
const (
DocumentTypeGeneral DocumentType = "general"
DocumentTypeWarranty DocumentType = "warranty"
DocumentTypeReceipt DocumentType = "receipt"
DocumentTypeContract DocumentType = "contract"
DocumentTypeInsurance DocumentType = "insurance"
DocumentTypeManual DocumentType = "manual"
)
// Document represents the task_document table
type Document struct {
BaseModel
ResidenceID uint `gorm:"column:residence_id;index;not null" json:"residence_id"`
Residence Residence `gorm:"foreignKey:ResidenceID" json:"-"`
CreatedByID uint `gorm:"column:created_by_id;index;not null" json:"created_by_id"`
CreatedBy User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
Title string `gorm:"column:title;size:200;not null" json:"title"`
Description string `gorm:"column:description;type:text" json:"description"`
DocumentType DocumentType `gorm:"column:document_type;size:20;default:'general'" json:"document_type"`
// File information
FileURL string `gorm:"column:file_url;size:500" json:"file_url"`
FileName string `gorm:"column:file_name;size:255" json:"file_name"`
FileSize *int64 `gorm:"column:file_size" json:"file_size"`
MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"`
// Warranty-specific fields
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"`
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
// Warranty provider contact fields
Provider string `gorm:"column:provider;size:200" json:"provider"`
ProviderContact string `gorm:"column:provider_contact;size:200" json:"provider_contact"`
ClaimPhone string `gorm:"column:claim_phone;size:50" json:"claim_phone"`
ClaimEmail string `gorm:"column:claim_email;size:200" json:"claim_email"`
ClaimWebsite string `gorm:"column:claim_website;size:500" json:"claim_website"`
Notes string `gorm:"column:notes;type:text" json:"notes"`
// Associated task (optional)
TaskID *uint `gorm:"column:task_id;index" json:"task_id"`
Task *Task `gorm:"foreignKey:TaskID" json:"task,omitempty"`
// State
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
// Multiple images support
Images []DocumentImage `gorm:"foreignKey:DocumentID" json:"images,omitempty"`
}
// TableName returns the table name for GORM
func (Document) TableName() string {
return "task_document"
}
// IsWarrantyExpiringSoon returns true if the warranty expires within the specified days
func (d *Document) IsWarrantyExpiringSoon(days int) bool {
if d.DocumentType != DocumentTypeWarranty || d.ExpiryDate == nil {
return false
}
threshold := time.Now().UTC().AddDate(0, 0, days)
return d.ExpiryDate.Before(threshold) && d.ExpiryDate.After(time.Now().UTC())
}
// IsWarrantyExpired returns true if the warranty has expired
func (d *Document) IsWarrantyExpired() bool {
if d.DocumentType != DocumentTypeWarranty || d.ExpiryDate == nil {
return false
}
return time.Now().UTC().After(*d.ExpiryDate)
}
// DocumentImage represents the task_documentimage table
type DocumentImage struct {
BaseModel
DocumentID uint `gorm:"column:document_id;index;not null" json:"document_id"`
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
Caption string `gorm:"column:caption;size:255" json:"caption"`
// PendingUploadID — see TaskCompletionImage.PendingUploadID.
PendingUploadID *uint `gorm:"column:pending_upload_id" json:"pending_upload_id,omitempty"`
}
// TableName returns the table name for GORM
func (DocumentImage) TableName() string {
return "task_documentimage"
}