Files
fm_be/internal/repository/mysql/file_manager_file_repo.go
2026-07-16 22:16:45 +07:00

493 lines
15 KiB
Go

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
}