Security: - Replace all binding: tags with validate: + c.Validate() in admin handlers - Add rate limiting to auth endpoints (login, register, password reset) - Add security headers (HSTS, XSS protection, nosniff, frame options) - Wire Google Pub/Sub token verification into webhook handler - Replace ParseUnverified with proper OIDC/JWKS key verification - Verify inner Apple JWS signatures in webhook handler - Add io.LimitReader (1MB) to all webhook body reads - Add ownership verification to file deletion - Move hardcoded admin credentials to env vars - Add uniqueIndex to User.Email - Hide ConfirmationCode from JSON serialization - Mask confirmation codes in admin responses - Use http.DetectContentType for upload validation - Fix path traversal in storage service - Replace os.Getenv with Viper in stripe service - Sanitize Redis URLs before logging - Separate DEBUG_FIXED_CODES from DEBUG flag - Reject weak SECRET_KEY in production - Add host check on /_next/* proxy routes - Use explicit localhost CORS origins in debug mode - Replace err.Error() with generic messages in all admin error responses Critical fixes: - Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth - Fix user_customuser -> auth_user table names in raw SQL - Fix dashboard verified query to use UserProfile model - Add escapeLikeWildcards() to prevent SQL wildcard injection Bug fixes: - Add bounds checks for days/expiring_soon query params (1-3650) - Add receipt_data/transaction_id empty-check to RestoreSubscription - Change Active bool -> *bool in device handler - Check all unchecked GORM/FindByIDWithProfile errors - Add validation for notification hour fields (0-23) - Add max=10000 validation on task description updates Transactions & data integrity: - Wrap registration flow in transaction - Wrap QuickComplete in transaction - Move image creation inside completion transaction - Wrap SetSpecialties in transaction - Wrap GetOrCreateToken in transaction - Wrap completion+image deletion in transaction Performance: - Batch completion summaries (2 queries vs 2N) - Reuse single http.Client in IAP validation - Cache dashboard counts (30s TTL) - Batch COUNT queries in admin user list - Add Limit(500) to document queries - Add reminder_stage+due_date filters to reminder queries - Parse AllowedTypes once at init - In-memory user cache in auth middleware (30s TTL) - Timezone change detection cache - Optimize P95 with per-endpoint sorted buffers - Replace crypto/md5 with hash/fnv for ETags Code quality: - Add sync.Once to all monitoring Stop()/Close() methods - Replace 8 fmt.Printf with zerolog in auth service - Log previously discarded errors - Standardize delete response shapes - Route hardcoded English through i18n - Remove FileURL from DocumentResponse (keep MediaURL only) - Thread user timezone through kanban board responses - Initialize empty slices to prevent null JSON - Extract shared field map for task Update/UpdateTx - Delete unused SoftDeleteModel, min(), formatCron, legacy handlers Worker & jobs: - Wire Asynq email infrastructure into worker - Register HandleReminderLogCleanup with daily 3AM cron - Use per-user timezone in HandleSmartReminder - Replace direct DB queries with repository calls - Delete legacy reminder handlers (~200 lines) - Delete unused task type constants Dependencies: - Replace archived jung-kurt/gofpdf with go-pdf/fpdf - Replace unmaintained gomail.v2 with wneessen/go-mail - Add TODO for Echo jwt v3 transitive dep removal Test infrastructure: - Fix MakeRequest/SeedLookupData error handling - Replace os.Exit(0) with t.Skip() in scope/consistency tests - Add 11 new FCM v1 tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
217 lines
6.9 KiB
Go
217 lines
6.9 KiB
Go
package repositories
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/honeydue-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").Limit(500).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
|
|
}
|