package mysql import ( "context" "errors" "fmt" "strings" "time" "gorm.io/gorm" filemanager "wucher/internal/domain/file_manager" "wucher/internal/shared/pkg/uuidv7" ) type FileManagerFileRepository struct { db *gorm.DB } func NewFileManagerFileRepository(db *gorm.DB) *FileManagerFileRepository { return &FileManagerFileRepository{db: db} } func (r *FileManagerFileRepository) Create(ctx context.Context, row *filemanager.File) error { return r.db.WithContext(ctx).Create(row).Error } func (r *FileManagerFileRepository) Update(ctx context.Context, row *filemanager.File) error { return r.db.WithContext(ctx).Save(row).Error } func (r *FileManagerFileRepository) TransitionStatus(ctx context.Context, params filemanager.FileStatusTransitionParams) error { expectedFrom := filemanager.NormalizeFileStatus(params.ExpectedFrom) toStatus := filemanager.NormalizeFileStatus(params.To) if expectedFrom == "" || toStatus == "" { return filemanager.ErrInvalidFileStatusTransition } if !filemanager.CanTransitionFileStatus(expectedFrom, toStatus) { return filemanager.ErrInvalidFileStatusTransition } now := time.Now().UTC() updates := map[string]any{ "status": toStatus, "updated_by": params.UpdatedBy, } switch toStatus { case filemanager.FileStatusProcessing: startedAt := now if params.ProcessingStartedAt != nil { startedAt = params.ProcessingStartedAt.UTC() } updates["processing_started_at"] = startedAt updates["processed_at"] = nil updates["validated_at"] = nil updates["failed_at"] = nil updates["failure_reason"] = nil updates["upload_error"] = "" case filemanager.FileStatusValidated: processedAt := now if params.ProcessedAt != nil { processedAt = params.ProcessedAt.UTC() } validatedAt := processedAt if params.ValidatedAt != nil { validatedAt = params.ValidatedAt.UTC() } updates["processed_at"] = processedAt updates["validated_at"] = validatedAt updates["failed_at"] = nil updates["failure_reason"] = nil updates["upload_error"] = "" case filemanager.FileStatusFailed: processedAt := now if params.ProcessedAt != nil { processedAt = params.ProcessedAt.UTC() } failedAt := now if params.FailedAt != nil { failedAt = params.FailedAt.UTC() } reason := "file processing failed" if params.FailureReason != nil { clean := strings.TrimSpace(*params.FailureReason) if clean != "" { reason = clean } } updates["processed_at"] = processedAt updates["failed_at"] = failedAt updates["validated_at"] = nil updates["failure_reason"] = reason updates["upload_error"] = reason } res := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("id = ? AND deleted_at IS NULL AND status = ?", params.ID, expectedFrom). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *FileManagerFileRepository) Finalize(ctx context.Context, params filemanager.FileFinalizeParams) error { expectedFrom := filemanager.NormalizeFileStatus(params.ExpectedFrom) if expectedFrom == "" { expectedFrom = filemanager.FileStatusValidated } updates := map[string]any{ "folder_id": params.FolderID, "name": params.Name, "name_normalized": params.NameNormalized, "extension": params.Extension, "status": filemanager.FileStatusReady, "name_slot": "live", "upload_error": "", "failure_reason": nil, "updated_by": params.UpdatedBy, } res := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("id = ? AND deleted_at IS NULL AND status = ?", params.ID, expectedFrom). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *FileManagerFileRepository) Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error { now := time.Now().UTC() nameSlot := deletedNameSlot(id) updates := map[string]any{ "status": filemanager.FileStatusTrashed, "trashed_at": now, "deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy, "name_slot": nameSlot, } if purgeAt != nil { v := purgeAt.UTC() updates["purge_at"] = v } res := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("id = ? AND deleted_at IS NULL AND status = ?", id, filemanager.FileStatusReady). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *FileManagerFileRepository) Restore(ctx context.Context, id []byte, restoredBy []byte) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var row filemanager.File if err := tx.Model(&filemanager.File{}).Where("id = ?", id).First(&row).Error; err != nil { return err } if row.DeletedAt == nil { return gorm.ErrRecordNotFound } var total int64 if err := tx.Model(&filemanager.File{}). Where("folder_id = ? AND name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", row.FolderID, row.NameNormalized). Count(&total).Error; err != nil { return err } if total > 0 { return gorm.ErrDuplicatedKey } updates := map[string]any{ "status": filemanager.FileStatusReady, "trashed_at": nil, "deleted_at": nil, "deleted_by": nil, "purge_at": nil, "updated_by": restoredBy, "name_slot": "live", } res := tx.Model(&filemanager.File{}). Where("id = ? AND deleted_at IS NOT NULL AND status = ?", id, filemanager.FileStatusTrashed). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil }) } func (r *FileManagerFileRepository) Purge(ctx context.Context, id []byte) error { res := r.db.WithContext(ctx). Where("id = ?", id). Delete(&filemanager.File{}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *FileManagerFileRepository) GetByID(ctx context.Context, id []byte) (*filemanager.File, error) { var row filemanager.File err := r.db.WithContext(ctx). Where("id = ? AND deleted_at IS NULL", id). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFileRepository) GetByIDAny(ctx context.Context, id []byte) (*filemanager.File, error) { var row filemanager.File err := r.db.WithContext(ctx). Where("id = ?", id). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFileRepository) GetByObjectKey(ctx context.Context, objectKey string) (*filemanager.File, error) { objectKey = strings.TrimSpace(objectKey) if objectKey == "" { return nil, nil } var row filemanager.File err := r.db.WithContext(ctx). Where("object_key = ? AND deleted_at IS NULL", objectKey). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFileRepository) GetByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) { var row filemanager.File err := r.db.WithContext(ctx). Where("folder_id = ? AND name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", folderID, nameNormalized). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFileRepository) GetByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error) { if len(templateUUID) != 16 || len(takeoverID) != 16 { return nil, nil } var row filemanager.File err := r.db.WithContext(ctx). Where("template_uuid = ? AND takeover_id = ? AND deleted_at IS NULL", templateUUID, takeoverID). Order("updated_at DESC, created_at DESC, id DESC"). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFileRepository) ListEditedTemplatesByTakeoverID(ctx context.Context, takeoverID []byte) ([]filemanager.File, error) { if len(takeoverID) != 16 { return nil, nil } rows := make([]filemanager.File, 0) err := r.db.WithContext(ctx). Where("takeover_id = ? AND template_uuid IS NOT NULL AND deleted_at IS NULL AND lifecycle_deleted_at IS NULL AND status != ?", takeoverID, filemanager.FileStatusTrashed). Order("created_at ASC, id ASC"). Find(&rows).Error return rows, err } func (r *FileManagerFileRepository) ListByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) { var rows []filemanager.File var total int64 base := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("folder_id = ? AND deleted_at IS NULL AND name_slot = 'live'", folderID) query := base if sort != "" { query = query.Order(sort) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *FileManagerFileRepository) SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]filemanager.File, int64, error) { var rows []filemanager.File var total int64 base := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("deleted_at IS NULL AND name_slot = 'live' AND name LIKE ?", "%"+name+"%") query := base if sort != "" { query = query.Order(sort) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *FileManagerFileRepository) ListTrashByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) { var rows []filemanager.File var total int64 base := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("deleted_at IS NOT NULL") if len(folderID) != 0 { base = base.Where("folder_id = ?", folderID) } query := base if sort != "" { query = query.Order(sort) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *FileManagerFileRepository) UpdateLifecycle(ctx context.Context, params filemanager.FileLifecycleTransitionParams) error { status := filemanager.NormalizeFileLifecycleStatus(params.Status) if !filemanager.IsKnownFileLifecycleStatus(status) { return fmt.Errorf("invalid file lifecycle status: %s", params.Status) } updates := map[string]any{ "lifecycle_status": status, "updated_by": params.UpdatedBy, } if params.AttachedAt != nil { updates["attached_at"] = params.AttachedAt.UTC() } if params.OrphanedAt != nil { updates["orphaned_at"] = params.OrphanedAt.UTC() } if params.ClearOrphanedAt { updates["orphaned_at"] = nil } if params.LifecycleDelete != nil { updates["lifecycle_deleted_at"] = params.LifecycleDelete.UTC() } res := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("id = ?", params.ID). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *FileManagerFileRepository) ListFilesByLifecycle(ctx context.Context, filter filemanager.FileLifecycleListFilter) ([]filemanager.File, error) { status := filemanager.NormalizeFileLifecycleStatus(filter.Status) if !filemanager.IsKnownFileLifecycleStatus(status) { return []filemanager.File{}, nil } query := r.db.WithContext(ctx). Model(&filemanager.File{}). Where("lifecycle_status = ?", status) if filter.OrphanedBeforeEq != nil { query = query.Where("orphaned_at IS NOT NULL AND orphaned_at <= ?", filter.OrphanedBeforeEq.UTC()) } if filter.CreatedBeforeEq != nil { query = query.Where("created_at <= ?", filter.CreatedBeforeEq.UTC()) } if filter.ExcludeInTrash { query = query.Where("deleted_at IS NULL") } if filter.ExcludeLifecycleDeleted { query = query.Where("lifecycle_deleted_at IS NULL") } if filter.Limit > 0 { query = query.Limit(filter.Limit) } rows := make([]filemanager.File, 0) if err := query.Order("orphaned_at ASC, id ASC").Find(&rows).Error; err != nil { return nil, err } return rows, nil } func (r *FileManagerFileRepository) IsFileReferenced(ctx context.Context, fileID []byte) (bool, error) { if len(fileID) != 16 { return false, nil } var total int64 queries := []struct { sql string args []any }{ {sql: "SELECT COUNT(1) FROM attachments WHERE file_id = ?", args: []any{fileID}}, {sql: "SELECT COUNT(1) FROM users u JOIN attachments a ON a.id = u.image_attachment_id WHERE a.file_id = ?", args: []any{fileID}}, {sql: "SELECT COUNT(1) FROM bases b JOIN attachments a ON a.id = b.foto_attachment_id WHERE a.file_id = ? AND b.deleted_at IS NULL", args: []any{fileID}}, {sql: "SELECT COUNT(1) FROM helicopters h JOIN attachments a ON a.id = h.foto_attachment_id WHERE a.file_id = ?", args: []any{fileID}}, {sql: "SELECT COUNT(1) FROM helicopter_files hf JOIN attachments a ON a.id = hf.file_attachment_id WHERE a.file_id = ?", args: []any{fileID}}, {sql: "SELECT COUNT(1) FROM hems_operational_files hof JOIN attachments a ON a.id = hof.file_attachment_id JOIN hems_operational_data hod ON hod.id = hof.operational_data_id WHERE a.file_id = ? AND hod.deleted_at IS NULL", args: []any{fileID}}, {sql: "SELECT COUNT(1) FROM master_settings ms JOIN file_files f ON f.id = ms.logo_file_id WHERE f.id = ? AND ms.deleted_at IS NULL", args: []any{fileID}}, {sql: "SELECT COUNT(1) FROM master_settings ms JOIN file_files f ON f.id = ms.cover_file_id WHERE f.id = ? AND ms.deleted_at IS NULL", args: []any{fileID}}, } for i := range queries { total = 0 if err := r.db.WithContext(ctx).Raw(queries[i].sql, queries[i].args...).Scan(&total).Error; err != nil { return false, err } if total > 0 { return true, nil } } fileUUID, err := uuidv7.BytesToString(fileID) if err == nil && strings.TrimSpace(fileUUID) != "" { legacyQueries := []string{ "SELECT COUNT(1) FROM pilot_profiles WHERE TRIM(photo_file_id) = ?", "SELECT COUNT(1) FROM doctor_profiles WHERE TRIM(photo_file_id) = ?", "SELECT COUNT(1) FROM air_rescuer_profiles WHERE TRIM(photo_file_id) = ?", "SELECT COUNT(1) FROM technician_profiles WHERE TRIM(photo_file_id) = ?", "SELECT COUNT(1) FROM flight_assistant_profiles WHERE TRIM(photo_file_id) = ?", "SELECT COUNT(1) FROM staff_profiles WHERE TRIM(photo_file_id) = ?", } for i := range legacyQueries { total = 0 if err := r.db.WithContext(ctx).Raw(legacyQueries[i], fileUUID).Scan(&total).Error; err != nil { return false, err } if total > 0 { return true, nil } } } return false, nil }