package service import ( "context" "errors" "path/filepath" "strings" "time" filemanager "wucher/internal/domain/file_manager" helicopterfile "wucher/internal/domain/helicopter_file" helicopterfilerealtime "wucher/internal/realtime/helicopterfile" "wucher/internal/shared/pkg/attachmentresolver" "wucher/internal/shared/pkg/uuidv7" ) type HelicopterFileService struct { repo helicopterfile.Repository fileManager filemanager.Service events helicopterFileEventPublisher } func NewHelicopterFileService(repo helicopterfile.Repository) *HelicopterFileService { return &HelicopterFileService{repo: repo} } type helicopterFileEventPublisher interface { PublishToUsers(userUUIDs []string, event helicopterfilerealtime.Event) int } func (s *HelicopterFileService) WithFileManagerService(fileManagerSvc filemanager.Service) *HelicopterFileService { s.fileManager = fileManagerSvc return s } func (s *HelicopterFileService) WithHelicopterFileEvents(publisher helicopterFileEventPublisher) *HelicopterFileService { s.events = publisher return s } func (s *HelicopterFileService) Create(ctx context.Context, row *helicopterfile.HelicopterFile) error { if row != nil && row.Position <= 0 { next, err := s.nextPositionByHelicopterSection(ctx, row.HelicopterID, row.Section) if err != nil { return err } row.Position = next } return s.repo.Create(ctx, row) } func (s *HelicopterFileService) Update(ctx context.Context, row *helicopterfile.HelicopterFile) error { return s.repo.Update(ctx, row) } func (s *HelicopterFileService) Delete(ctx context.Context, id []byte) error { return s.repo.Delete(ctx, id) } func (s *HelicopterFileService) GetByID(ctx context.Context, id []byte) (*helicopterfile.HelicopterFile, error) { return s.repo.GetByID(ctx, id) } func (s *HelicopterFileService) List(ctx context.Context, filter string, sort string, limit, offset int) ([]helicopterfile.HelicopterFile, int64, error) { return s.repo.List(ctx, filter, normalizeHelicopterFileSort(sort), limit, offset) } func (s *HelicopterFileService) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]helicopterfile.HelicopterFile, error) { return s.repo.ListByHelicopter(ctx, helicopterID) } func (s *HelicopterFileService) ListByHelicopterAndSection(ctx context.Context, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) { return s.repo.ListByHelicopterAndSection(ctx, helicopterID, section) } func (s *HelicopterFileService) HelicopterExists(ctx context.Context, helicopterID []byte) (bool, error) { return s.repo.HelicopterExists(ctx, helicopterID) } func (s *HelicopterFileService) AttachmentExists(ctx context.Context, attachmentID []byte) (bool, error) { return s.repo.AttachmentExists(ctx, attachmentID) } func (s *HelicopterFileService) RequestUploadIntentsBulk(ctx context.Context, inputs []helicopterfile.UploadIntentBulkInput, actorID []byte) []helicopterfile.UploadIntentBulkItemResult { items := make([]helicopterfile.UploadIntentBulkItemResult, len(inputs)) if s.fileManager == nil { for i := range items { items[i] = helicopterfile.UploadIntentBulkItemResult{ Index: i, Success: false, Error: &helicopterfile.BulkItemError{ Status: 400, Title: "Upload intent failed", Detail: "file manager service is not configured", }, } } return items } for i := range inputs { item := inputs[i] typ := strings.TrimSpace(item.Type) if typ != "helicopter_file_upload" { items[i] = uploadIntentBulkError(i, 422, "Validation error", "type must be helicopter_file_upload") continue } name := strings.TrimSpace(item.Name) if name == "" { items[i] = uploadIntentBulkError(i, 422, "Validation error", "name is required") continue } contentType := strings.TrimSpace(item.ContentType) if contentType == "" { items[i] = uploadIntentBulkError(i, 422, "Validation error", "content_type is required") continue } if item.SizeBytes < 0 { items[i] = uploadIntentBulkError(i, 422, "Validation error", "size_bytes must be greater than or equal to 0") continue } grant, grantErr := s.fileManager.RequestFileUploadIntent(ctx, filemanager.RequestFileUploadIntentParams{ Name: name, ContentType: contentType, SizeBytes: item.SizeBytes, ActorID: actorID, }) if grantErr != nil { items[i] = uploadIntentBulkError(i, 400, "Upload intent failed", grantErr.Error()) continue } items[i] = helicopterfile.UploadIntentBulkItemResult{ Index: i, Success: true, Grant: grant, } } return items } func (s *HelicopterFileService) CreateFromUploadIntentsBulk(ctx context.Context, inputs []helicopterfile.CreateBulkInput, actorID []byte) []helicopterfile.CreateBulkItemResult { items := make([]helicopterfile.CreateBulkItemResult, len(inputs)) if s.fileManager == nil { for i := range items { items[i] = createBulkError(i, 400, "Create failed", "file manager service is not configured") } return items } for i := range inputs { item := inputs[i] typ := strings.TrimSpace(item.Type) if typ != "helicopter_file_create" && typ != "helicopter_file" { items[i] = createBulkError(i, 422, "Validation error", "type must be helicopter_file_create or helicopter_file") continue } section := strings.TrimSpace(item.Section) if !isValidHelicopterFileSection(section) { items[i] = createBulkError(i, 422, "Validation error", "section is invalid") continue } helicopterID, err := uuidv7.ParseString(strings.TrimSpace(item.HelicopterID)) if err != nil { items[i] = createBulkError(i, 422, "Validation error", "helicopter_id is invalid UUID") continue } exists, err := s.repo.HelicopterExists(ctx, helicopterID) if err != nil { items[i] = createBulkError(i, 400, "Create failed", err.Error()) continue } if !exists { items[i] = createBulkError(i, 422, "Validation error", "helicopter_id not found") continue } uploadIntentID, parseErr := uuidv7.ParseString(strings.TrimSpace(item.UploadIntentUUID)) if parseErr != nil { items[i] = createBulkError(i, 422, "Validation error", "upload_intent_uuid is invalid UUID") continue } fileName := strings.TrimSpace(item.FileName) if fileName == "" { items[i] = createBulkError(i, 422, "Validation error", "file_name is required") continue } var folderID []byte folderProvided := false if item.FolderUUID != nil { folderProvided = true folderUUID := strings.TrimSpace(*item.FolderUUID) if folderUUID != "" { parsedFolderID, folderErr := uuidv7.ParseString(folderUUID) if folderErr != nil { items[i] = createBulkError(i, 422, "Validation error", "folder_uuid is invalid UUID") continue } folderID = parsedFolderID } } createdFile, createErr := s.fileManager.CreateFileNodeFromUploadIntent(ctx, filemanager.CreateFileFromUploadIntentParams{ UploadIntentID: uploadIntentID, FolderID: folderID, FolderProvided: folderProvided, Name: fileName, ActorID: actorID, IsTemplate: true, }) if createErr != nil { if errors.Is(createErr, filemanager.ErrDuplicateName) { targetFolderID := folderID if !folderProvided || len(targetFolderID) == 0 { root, rootErr := s.fileManager.GetOrCreateSystemRootFolder(ctx) if rootErr != nil || root == nil { detail := "unable to resolve target folder" if rootErr != nil { detail = rootErr.Error() } items[i] = createBulkError(i, 400, "Create failed", detail) continue } targetFolderID = root.ID } nameNormalized := strings.ToLower(filepath.Base(strings.TrimSpace(fileName))) if nameNormalized == "" { items[i] = createBulkError(i, 422, "Validation error", "file_name is invalid") continue } existingFile, existingErr := s.fileManager.GetFileByFolderAndName(ctx, targetFolderID, nameNormalized) if existingErr != nil || existingFile == nil { detail := "duplicate file exists but cannot be resolved" if existingErr != nil { detail = existingErr.Error() } items[i] = createBulkError(i, 400, "Create failed", detail) continue } createdFile = existingFile } else { items[i] = createBulkError(i, 400, "Create failed", createErr.Error()) continue } } if createdFile == nil { items[i] = createBulkError(i, 400, "Create failed", "unable to create file from upload intent") continue } position := 0 if item.Position != nil { position = *item.Position } isMandatory := false if item.IsMandatory != nil { isMandatory = *item.IsMandatory } row := &helicopterfile.HelicopterFile{ Section: section, Position: position, IsMandatory: isMandatory, HelicopterID: helicopterID, SourceFileID: append([]byte(nil), createdFile.ID...), CreatedBy: actorID, UpdatedBy: actorID, } if row.Position <= 0 { next, nextErr := s.nextPositionByHelicopterSection(ctx, row.HelicopterID, row.Section) if nextErr != nil { items[i] = createBulkError(i, 400, "Create failed", nextErr.Error()) continue } row.Position = next } if err := s.repo.Create(ctx, row); err != nil { items[i] = createBulkError(i, 400, "Create failed", err.Error()) continue } createdRow, getErr := s.repo.GetByID(ctx, row.ID) if getErr == nil && createdRow != nil { items[i] = helicopterfile.CreateBulkItemResult{ Index: i, Success: true, Row: createdRow, } continue } items[i] = helicopterfile.CreateBulkItemResult{ Index: i, Success: true, Row: row, } } return items } func (s *HelicopterFileService) FinalizePendingAttachments(ctx context.Context, limit int) (int, error) { if s == nil || s.repo == nil { return 0, errors.New("helicopter file service is not configured") } if s.fileManager == nil { return 0, nil } rows, err := s.repo.ListPendingAttachments(ctx, limit) if err != nil { return 0, err } return s.finalizePendingAttachmentRows(ctx, rows), nil } func (s *HelicopterFileService) FinalizePendingAttachmentsBySourceFileUUID(ctx context.Context, sourceFileUUID string, limit int) (int, error) { if s == nil || s.repo == nil { return 0, errors.New("helicopter file service is not configured") } if s.fileManager == nil { return 0, nil } sourceFileUUID = strings.TrimSpace(sourceFileUUID) if sourceFileUUID == "" { return 0, nil } sourceFileID, err := uuidv7.ParseString(sourceFileUUID) if err != nil { return 0, err } rows, err := s.repo.ListPendingAttachmentsBySourceFileID(ctx, sourceFileID, limit) if err != nil { return 0, err } return s.finalizePendingAttachmentRows(ctx, rows), nil } func (s *HelicopterFileService) finalizePendingAttachmentRows(ctx context.Context, rows []helicopterfile.HelicopterFile) int { finalized := 0 for i := range rows { row := &rows[i] if len(row.ID) != 16 || len(row.SourceFileID) != 16 || len(row.FileAttachmentID) == 16 { continue } attachmentID, ok, err := s.finalizePendingAttachment(ctx, row) if err != nil || !ok || len(attachmentID) != 16 { continue } finalized++ } return finalized } func (s *HelicopterFileService) finalizePendingAttachment(ctx context.Context, row *helicopterfile.HelicopterFile) ([]byte, bool, error) { if s == nil || row == nil || s.fileManager == nil { return nil, false, nil } if len(row.SourceFileID) != 16 || len(row.ID) != 16 || len(row.HelicopterID) != 16 { return nil, false, nil } sourceFileUUID := uuidBytesToStringOrEmpty(row.SourceFileID) if sourceFileUUID == "" { return nil, false, nil } helicopterUUID := uuidBytesToStringOrEmpty(row.HelicopterID) if helicopterUUID == "" { return nil, false, nil } category := "helicopter_file" attachmentID, err := attachmentresolver.Resolve(ctx, attachmentresolver.Params{ FileManager: s.fileManager, RawFileID: &sourceFileUUID, AttachmentField: "file_attachment_id", FileField: "source_file_id", RequireValue: true, RefType: "helicopter", RefID: helicopterUUID, Category: &category, IsPrimary: false, ActorID: append([]byte(nil), row.UpdatedBy...), }) if err != nil { var resolveErr *attachmentresolver.ResolveError if errors.As(err, &resolveErr) && resolveErr.Code == attachmentresolver.ErrorFileNotReadyForAttachment { return nil, false, nil } return nil, false, err } if len(attachmentID) != 16 { return nil, false, nil } row.FileAttachmentID = append([]byte(nil), attachmentID...) row.SourceFileID = nil row.UpdatedAt = time.Now().UTC() if len(row.UpdatedBy) == 0 { row.UpdatedBy = append([]byte(nil), row.CreatedBy...) } if err := s.repo.Update(ctx, row); err != nil { return nil, false, err } s.publishHelicopterFileState(row, attachmentID) if err := s.markAttachmentFileActive(ctx, attachmentID, row.UpdatedBy); err != nil { return attachmentID, true, err } return attachmentID, true, nil } func (s *HelicopterFileService) publishHelicopterFileState(row *helicopterfile.HelicopterFile, fileID []byte) { if s == nil || s.events == nil || row == nil || len(row.ID) != 16 || len(row.HelicopterID) != 16 { return } actorID := row.UpdatedBy if len(actorID) != 16 { actorID = row.CreatedBy } if len(actorID) != 16 { return } actorUUID := uuidBytesToStringOrEmpty(actorID) helicopterUUID := uuidBytesToStringOrEmpty(row.HelicopterID) helicopterFileUUID := uuidBytesToStringOrEmpty(row.ID) attachmentUUID := uuidBytesToStringOrEmpty(fileID) if attachmentUUID == "" { attachmentUUID = uuidBytesToStringOrEmpty(row.FileAttachmentID) } if actorUUID == "" || helicopterUUID == "" || helicopterFileUUID == "" || attachmentUUID == "" { return } updatedAt := row.UpdatedAt.UTC() if updatedAt.IsZero() { updatedAt = time.Now().UTC() } sourceFile := row.SourceFile name := "" mimeType := "" if sourceFile != nil { name = strings.TrimSpace(sourceFile.Name) mimeType = strings.TrimSpace(sourceFile.MimeType) } s.events.PublishToUsers([]string{actorUUID}, helicopterfilerealtime.Event{ EventType: helicopterfilerealtime.DefaultEventType, HelicopterID: helicopterUUID, HelicopterFileID: helicopterFileUUID, FileID: attachmentUUID, Section: strings.TrimSpace(row.Section), Name: name, MimeType: mimeType, AttachmentPending: len(row.FileAttachmentID) != 16 && len(row.SourceFileID) == 16, Position: row.Position, IsMandatory: row.IsMandatory, UpdatedAt: updatedAt.Format(time.RFC3339), }) } func (s *HelicopterFileService) markAttachmentFileActive(ctx context.Context, attachmentID []byte, actorID []byte) error { if s == nil || s.fileManager == nil || len(attachmentID) != 16 { return nil } lifecycle, ok := s.fileManager.(filemanager.LifecycleManager) if !ok { return nil } attachment, err := s.fileManager.GetAttachmentByID(ctx, attachmentID) if err != nil || attachment == nil || len(attachment.FileID) != 16 { return err } return lifecycle.MarkFileActive(ctx, attachment.FileID, actorID) } func (s *HelicopterFileService) nextPositionByHelicopterSection(ctx context.Context, helicopterID []byte, section string) (int, error) { rows, err := s.repo.ListByHelicopterAndSection(ctx, helicopterID, section) if err != nil { return 0, err } maxPos := 0 for i := range rows { if rows[i].Position > maxPos { maxPos = rows[i].Position } } return maxPos + 1, nil } func uploadIntentBulkError(index int, status int, title, detail string) helicopterfile.UploadIntentBulkItemResult { return helicopterfile.UploadIntentBulkItemResult{ Index: index, Success: false, Error: &helicopterfile.BulkItemError{ Status: status, Title: title, Detail: detail, }, } } func createBulkError(index int, status int, title, detail string) helicopterfile.CreateBulkItemResult { return helicopterfile.CreateBulkItemResult{ Index: index, Success: false, Error: &helicopterfile.BulkItemError{ Status: status, Title: title, Detail: detail, }, } } func isValidHelicopterFileSection(section string) bool { switch strings.TrimSpace(section) { case helicopterfile.SectionBeforeFirstFlightInspection, helicopterfile.SectionFlightPreparationAndWB, helicopterfile.SectionAfterLastFlightInspection: return true default: return false } } func uuidBytesToStringOrEmpty(id []byte) string { s, err := uuidv7.BytesToString(id) if err != nil { return "" } return s } func normalizeHelicopterFileSort(sort string) string { if normalized := normalizeSortByRules(strings.TrimSpace(sort), sortExpr("section ASC, position ASC, created_at ASC", "section DESC, position DESC, created_at DESC", "section"), sortField("position"), sortField("is_mandatory"), sortField("created_at"), sortField("updated_at"), ); normalized != "" { return normalized } return "section ASC, position ASC, created_at ASC" }