Files
honeyDueAPI/internal/repositories/document_repo.go
Trey t 7690f07a2b Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:48:01 -06:00

217 lines
6.9 KiB
Go

package repositories
import (
"time"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
)
// DocumentFilter contains optional filter parameters for listing documents.
type DocumentFilter struct {
ResidenceID *uint
DocumentType string
IsActive *bool
ExpiringSoon *int
Search string
}
// DocumentRepository handles database operations for documents
type DocumentRepository struct {
db *gorm.DB
}
// NewDocumentRepository creates a new document repository
func NewDocumentRepository(db *gorm.DB) *DocumentRepository {
return &DocumentRepository{db: db}
}
// FindByID finds a document by ID with preloaded relations
func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
var document models.Document
err := r.db.Preload("CreatedBy").
Preload("Task").
Preload("Images").
Where("id = ? AND is_active = ?", id, true).
First(&document).Error
if err != nil {
return nil, err
}
return &document, nil
}
// FindByResidence finds all documents for a residence
func (r *DocumentRepository) FindByResidence(residenceID uint) ([]models.Document, error) {
var documents []models.Document
err := r.db.Preload("CreatedBy").
Preload("Images").
Where("residence_id = ? AND is_active = ?", residenceID, true).
Order("created_at DESC").
Find(&documents).Error
return documents, err
}
// FindByUser finds all documents accessible to a user.
// A default limit of 500 is applied to prevent unbounded result sets.
func (r *DocumentRepository) FindByUser(residenceIDs []uint) ([]models.Document, error) {
var documents []models.Document
err := r.db.Preload("CreatedBy").
Preload("Residence").
Preload("Images").
Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
Order("created_at DESC").
Limit(500).
Find(&documents).Error
return documents, err
}
// FindByUserFiltered finds documents accessible to a user with optional filters.
func (r *DocumentRepository) FindByUserFiltered(residenceIDs []uint, filter *DocumentFilter) ([]models.Document, error) {
query := r.db.Preload("CreatedBy").
Preload("Residence").
Preload("Images").
Where("residence_id IN ?", residenceIDs)
// Default behavior is active-only unless explicitly overridden.
if filter == nil || filter.IsActive == nil {
query = query.Where("is_active = ?", true)
}
if filter != nil {
if filter.DocumentType != "" {
query = query.Where("document_type = ?", filter.DocumentType)
}
if filter.IsActive != nil {
query = query.Where("is_active = ?", *filter.IsActive)
}
if filter.ExpiringSoon != nil {
now := time.Now().UTC()
threshold := now.AddDate(0, 0, *filter.ExpiringSoon)
query = query.Where("expiry_date IS NOT NULL AND expiry_date > ? AND expiry_date <= ?", now, threshold)
}
if filter.Search != "" {
escaped := escapeLikeWildcards(filter.Search)
searchPattern := "%" + escaped + "%"
query = query.Where("(title ILIKE ? OR description ILIKE ?)", searchPattern, searchPattern)
}
}
var documents []models.Document
err := query.Order("created_at DESC").Find(&documents).Error
return documents, err
}
// FindWarranties finds all warranty documents
func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Document, error) {
var documents []models.Document
err := r.db.Preload("CreatedBy").
Preload("Residence").
Preload("Images").
Where("residence_id IN ? AND is_active = ? AND document_type = ?",
residenceIDs, true, models.DocumentTypeWarranty).
Order("expiry_date ASC NULLS LAST").
Find(&documents).Error
return documents, err
}
// FindExpiringWarranties finds warranties expiring within the specified days
func (r *DocumentRepository) FindExpiringWarranties(residenceIDs []uint, days int) ([]models.Document, error) {
threshold := time.Now().UTC().AddDate(0, 0, days)
now := time.Now().UTC()
var documents []models.Document
err := r.db.Preload("CreatedBy").
Preload("Residence").
Preload("Images").
Where("residence_id IN ? AND is_active = ? AND document_type = ? AND expiry_date > ? AND expiry_date <= ?",
residenceIDs, true, models.DocumentTypeWarranty, now, threshold).
Order("expiry_date ASC").
Find(&documents).Error
return documents, err
}
// Create creates a new document
func (r *DocumentRepository) Create(document *models.Document) error {
return r.db.Create(document).Error
}
// Update updates a document
// Uses Omit to exclude associations that could interfere with Save
func (r *DocumentRepository) Update(document *models.Document) error {
return r.db.Omit("CreatedBy", "Task", "Images", "Residence").Save(document).Error
}
// Delete soft-deletes a document
func (r *DocumentRepository) Delete(id uint) error {
return r.db.Model(&models.Document{}).
Where("id = ?", id).
Update("is_active", false).Error
}
// Activate activates a document
func (r *DocumentRepository) Activate(id uint) error {
return r.db.Model(&models.Document{}).
Where("id = ?", id).
Update("is_active", true).Error
}
// Deactivate deactivates a document
func (r *DocumentRepository) Deactivate(id uint) error {
return r.db.Model(&models.Document{}).
Where("id = ?", id).
Update("is_active", false).Error
}
// CountByResidence counts documents in a residence
func (r *DocumentRepository) CountByResidence(residenceID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Document{}).
Where("residence_id = ? AND is_active = ?", residenceID, true).
Count(&count).Error
return count, err
}
// CountByResidenceIDs counts all active documents across multiple residences in a single query.
// Returns the total count of active documents for the given residence IDs.
func (r *DocumentRepository) CountByResidenceIDs(residenceIDs []uint) (int64, error) {
if len(residenceIDs) == 0 {
return 0, nil
}
var count int64
err := r.db.Model(&models.Document{}).
Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
Count(&count).Error
return count, err
}
// FindByIDIncludingInactive finds a document by ID including inactive ones
func (r *DocumentRepository) FindByIDIncludingInactive(id uint, document *models.Document) error {
return r.db.Preload("CreatedBy").Preload("Images").First(document, id).Error
}
// CreateDocumentImage creates a new document image
func (r *DocumentRepository) CreateDocumentImage(image *models.DocumentImage) error {
return r.db.Create(image).Error
}
// DeleteDocumentImage deletes a document image
func (r *DocumentRepository) DeleteDocumentImage(id uint) error {
return r.db.Delete(&models.DocumentImage{}, id).Error
}
// DeleteDocumentImages deletes all images for a document
func (r *DocumentRepository) DeleteDocumentImages(documentID uint) error {
return r.db.Where("document_id = ?", documentID).Delete(&models.DocumentImage{}).Error
}
// FindImageByID finds a document image by ID
func (r *DocumentRepository) FindImageByID(id uint) (*models.DocumentImage, error) {
var image models.DocumentImage
err := r.db.First(&image, id).Error
if err != nil {
return nil, err
}
return &image, nil
}