package handlers import ( "bytes" "context" "errors" "io" "path/filepath" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" filemanager "wucher/internal/domain/file_manager" "wucher/internal/domain/helicopter" helicopterfile "wucher/internal/domain/helicopter_file" takeoverdomain "wucher/internal/domain/takeover" helicopterfilerealtime "wucher/internal/realtime/helicopterfile" "wucher/internal/shared/pkg/apperrorsx" "wucher/internal/shared/pkg/uuidv7" requestdto "wucher/internal/transport/http/dto/request" responsedto "wucher/internal/transport/http/dto/response" "wucher/internal/transport/http/jsonapi" "wucher/internal/transport/http/response" "wucher/internal/transport/http/validators" ) const ( helicopterFileBulkMaxItems = 20 ) var ( _ responsedto.HelicopterFileResponse _ responsedto.HelicopterFileListResponse ) type HelicopterFileHandler struct { svc helicopterfile.Service helicopter helicopter.Service takeover helicopterFileTakeoverLookup fileManager filemanager.Service helicopterFileEvents helicopterFileEventPublisher storage fileManagerDownloadURLStorage validate *validators.Validator } type helicopterFileTakeoverLookup interface { GetByID(ctx context.Context, id []byte) (*takeoverdomain.TakeoverAc, error) } func NewHelicopterFileHandler(svc helicopterfile.Service) *HelicopterFileHandler { return &HelicopterFileHandler{ svc: svc, validate: validators.New(), } } func (h *HelicopterFileHandler) WithFileManagerService(fileManagerSvc filemanager.Service) *HelicopterFileHandler { h.fileManager = fileManagerSvc return h } func (h *HelicopterFileHandler) WithHelicopterService(helicopterSvc helicopter.Service) *HelicopterFileHandler { h.helicopter = helicopterSvc return h } func (h *HelicopterFileHandler) WithTakeoverService(takeoverSvc helicopterFileTakeoverLookup) *HelicopterFileHandler { h.takeover = takeoverSvc return h } func (h *HelicopterFileHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *HelicopterFileHandler { h.storage = storage return h } func (h *HelicopterFileHandler) WithHelicopterFileEvents(publisher helicopterFileEventPublisher) *HelicopterFileHandler { h.helicopterFileEvents = publisher return h } // UploadHelicopterFile godoc // @Summary Request upload intents + presigned PUT URLs (helicopter file) // @Description Control-plane endpoint for helicopter file upload intent + presigned URL flow. // @Description Public flow: POST /api/v1/helicopter-files/upload -> PUT to presigned URL -> POST /api/v1/helicopter-files/create. // @Description Canonical type is `helicopter_file_upload`. // @Description `required_headers` must be sent exactly as returned when doing PUT to `upload_url`. // @Description Example `required_headers`: `{"content-type":"application/pdf","x-amz-acl":"private"}` // @Description Supports single and bulk payload. // @Description Single payload example: `{"data":{"type":"helicopter_file_upload","attributes":{"name":"A.pdf","content_type":"application/pdf","size_bytes":12345}}}` // @Description Bulk payload example: `{"data":[{"type":"helicopter_file_upload","attributes":{"name":"A.pdf","content_type":"application/pdf","size_bytes":12345}}]}` // @Description Note: OpenAPI 2.0 cannot express object-or-array union in one schema; request schema below represents bulk envelope. // @Tags Helicopter - Master File // @Accept json // @Produce json // @Param request body requestdto.HelicopterFileUploadBulkRequest true "Helicopter file upload intent request (single object or bulk array)" // @Success 201 {object} responsedto.HelicopterFileUploadIntentResponse // @Success 200 {object} responsedto.HelicopterFileUploadIntentBulkResponse // @Success 207 {object} responsedto.HelicopterFileUploadIntentBulkResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopter-files/upload [post] func (h *HelicopterFileHandler) Upload(c *fiber.Ctx) error { if h.fileManager == nil { writeHelicopterFileBadRequestError(c, "Upload failed", "file manager service is not configured") return nil } isBulk, err := isJSONAPIDataArrayPayload(c.Body()) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if isBulk { return h.uploadBulk(c) } var req requestdto.HelicopterFileUploadRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, errs) } typ := strings.TrimSpace(req.Data.Type) if typ != "helicopter_file_upload" { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "type must be helicopter_file_upload", Source: &jsonapi.ErrorSource{Pointer: "/data/type"}, }}) } name := strings.TrimSpace(req.Data.Attributes.Name) contentType := strings.TrimSpace(req.Data.Attributes.ContentType) sizeBytes := req.Data.Attributes.SizeBytes grant, grantErr := h.fileManager.RequestFileUploadIntent(c.Context(), filemanager.RequestFileUploadIntentParams{ Name: name, ContentType: contentType, SizeBytes: sizeBytes, ActorID: actorUserID(c), }) if grantErr != nil { return writeFileManagerActionError(c, "Upload intent failed", grantErr) } baseResource := fileManagerFileUploadIntentResource(grant) resource := responsedto.HelicopterFileUploadIntentResource{ Type: "helicopter_file_upload_intent", ID: baseResource.ID, Attributes: baseResource.Attributes, } return response.Write(c, fiber.StatusCreated, resource) } func (h *HelicopterFileHandler) uploadBulk(c *fiber.Ctx) error { var req requestdto.HelicopterFileUploadBulkRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if len(req.Data) == 0 { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "data must contain at least one item", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } if len(req.Data) > helicopterFileBulkMaxItems { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "too many items in one request", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } inputs := make([]helicopterfile.UploadIntentBulkInput, len(req.Data)) for i := range req.Data { item := req.Data[i] inputs[i] = helicopterfile.UploadIntentBulkInput{ Type: strings.TrimSpace(item.Type), Name: strings.TrimSpace(item.Attributes.Name), ContentType: strings.TrimSpace(item.Attributes.ContentType), SizeBytes: item.Attributes.SizeBytes, } } results := h.svc.RequestUploadIntentsBulk(c.Context(), inputs, actorUserID(c)) items := make([]responsedto.HelicopterFileUploadIntentBulkItem, len(results)) for i := range results { result := results[i] if result.Success && result.Grant != nil { baseResource := fileManagerFileUploadIntentResource(result.Grant) resource := responsedto.HelicopterFileUploadIntentResource{ Type: "helicopter_file_upload_intent", ID: baseResource.ID, Attributes: baseResource.Attributes, } items[i] = responsedto.HelicopterFileUploadIntentBulkItem{ Index: result.Index, Success: true, Resource: &resource, } continue } errStatus := fiber.StatusInternalServerError errTitle := "Internal error" errDetail := "missing item error" if result.Error != nil { errStatus = result.Error.Status errTitle = result.Error.Title errDetail = result.Error.Detail } items[i] = helicopterFileUploadBulkErrorItem(result.Index, errStatus, errTitle, errDetail) } return writeHelicopterFileUploadIntentBulkResponse(c, items) } // CancelUploadHelicopterFile godoc // @Summary Cancel helicopter file upload intent (bulk) // @Description Cancel pending upload intents from helicopter file upload flow. // @Description Bulk payload accepts one or many items. // @Tags Helicopter - Master File // @Accept json // @Produce json // @Param request body requestdto.HelicopterFileCancelUploadBulkRequest true "Bulk helicopter file cancel upload intent request" // @Success 200 {object} responsedto.FileManagerFileCancelUploadBulkResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopter-files/cancel-upload [post] func (h *HelicopterFileHandler) CancelUpload(c *fiber.Ctx) error { if h.fileManager == nil { writeHelicopterFileBadRequestError(c, "Cancel upload failed", "file manager service is not configured") return nil } isBulk, err := isJSONAPIDataArrayPayload(c.Body()) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if !isBulk { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "data must be an array", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } var req requestdto.HelicopterFileCancelUploadBulkRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if len(req.Data) == 0 { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "data must contain at least one item", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } items := make([]responsedto.FileManagerFileCancelUploadBulkItem, len(req.Data)) for i := range req.Data { item := req.Data[i] if strings.TrimSpace(item.Type) != "helicopter_file_cancel_upload" { items[i] = fileManagerCancelUploadBulkSystemErrorItem(i, fiber.StatusUnprocessableEntity, "Validation error", "type must be helicopter_file_cancel_upload") continue } uploadIntentID, parseErr := uuidv7.ParseString(strings.TrimSpace(item.Attributes.UploadIntentUUID)) if parseErr != nil { items[i] = fileManagerCancelUploadBulkSystemErrorItem(i, fiber.StatusUnprocessableEntity, "Validation error", "upload_intent_uuid is invalid UUID") continue } if cancelErr := h.fileManager.CancelFileUploadIntent(c.UserContext(), filemanager.CancelFileUploadIntentParams{ UploadIntentID: uploadIntentID, ActorID: actorUserID(c), }); cancelErr != nil { items[i] = fileManagerCancelUploadBulkActionErrorItem(i, "Cancel upload failed", cancelErr) continue } items[i] = fileManagerCancelUploadBulkSuccessItem(i, uploadIntentID) } return writeFileManagerCancelUploadBulkResponse(c, items) } // CreateHelicopterFile godoc // @Summary Create helicopter file // @Description Create helicopter file template record from upload intent. // @Description Section allowed values: `bfi` (Before First Flight Inspection), `fpwb` (Flight Preparation and WB), `afi` (After Last Flight Inspection). // @Description Required attributes: `section`, `helicopter_id`, `upload_intent_uuid`, `file_name`. // @Description Optional attributes: `position`, `is_required`, `folder_uuid` (if omitted, file is created in root folder). // @Description When `position` is omitted (or <= 0), backend auto-assigns next position per `helicopter_id + section`. // @Description If file name already exists in target folder, existing file is reused and attached. // @Description Supports single and bulk payload. // @Description Single payload example: `{"data":{"type":"helicopter_file_create","attributes":{"section":"bfi","position":1,"is_required":true,"helicopter_id":"019d6771-5fb5-7337-837f-bc5ed85181a1","upload_intent_uuid":"019d6d0d-2de2-7db1-80a2-2862d43af620","file_name":"A.pdf"}}}` // @Description Bulk payload example: `{"data":[{"type":"helicopter_file_create","attributes":{"section":"bfi","position":1,"is_required":true,"helicopter_id":"019d6771-5fb5-7337-837f-bc5ed85181a1","upload_intent_uuid":"019d6d0d-2de2-7db1-80a2-2862d43af620","file_name":"A.pdf"}}]}` // @Description Note: OpenAPI 2.0 cannot express object-or-array union in one schema; request schema below represents bulk envelope. // @Tags Helicopter - Master File // @Accept json // @Produce json // @Param request body requestdto.HelicopterFileCreateBulkRequest true "JSON:API helicopter file create request (single object or bulk array)" // @Success 201 {object} responsedto.HelicopterFileResponse // @Success 200 {object} responsedto.HelicopterFileCreateBulkResponse // @Success 207 {object} responsedto.HelicopterFileCreateBulkResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopter-files/create [post] func (h *HelicopterFileHandler) Create(c *fiber.Ctx) error { isBulk, err := isJSONAPIDataArrayPayload(c.Body()) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if isBulk { return h.createBulk(c) } var req requestdto.HelicopterFileCreateRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, errs) } section := strings.TrimSpace(req.Data.Attributes.Section) if !isValidHelicopterFileSection(section) { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "section is invalid", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/section"}, }}) } attrs := req.Data.Attributes helicopterRaw := strings.TrimSpace(attrs.HelicopterID) if helicopterRaw == "" { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "helicopter_id is required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/helicopter_id"}, }}) } helicopterID, err := uuidv7.ParseString(helicopterRaw) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "helicopter_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/helicopter_id"}, }}) } exists, err := h.svc.HelicopterExists(c.UserContext(), helicopterID) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Create failed", Detail: safeInternalDetail(), }}) } if !exists { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "helicopter_id not found", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/helicopter_id"}, }}) } uploadIntentUUID := strings.TrimSpace(attrs.UploadIntentUUID) fileName := strings.TrimSpace(attrs.FileName) uploadIntentID, parseErr := uuidv7.ParseString(uploadIntentUUID) if parseErr != nil { writeHelicopterFileValidationError(c, "upload_intent_uuid is invalid UUID", "/data/attributes/upload_intent_uuid") return nil } if fileName == "" { writeHelicopterFileValidationError(c, "file_name is required", "/data/attributes/file_name") return nil } if h.fileManager == nil { writeHelicopterFileBadRequestError(c, "Create failed", "file manager service is not configured") return nil } var folderID []byte folderProvided := false if attrs.FolderUUID != nil { folderProvided = true folderUUID := strings.TrimSpace(*attrs.FolderUUID) if folderUUID != "" { parsedFolderID, folderErr := uuidv7.ParseString(folderUUID) if folderErr != nil { writeHelicopterFileValidationError(c, "folder_uuid is invalid UUID", "/data/attributes/folder_uuid") return nil } folderID = parsedFolderID } } else { root, rootErr := h.fileManager.GetOrCreateSystemRootFolder(c.UserContext()) if rootErr == nil && root != nil && len(root.ID) == 16 { folderID = root.ID folderProvided = true } } reusedExistingFile := false createdFile, createErr := h.fileManager.CreateFileNodeFromUploadIntent(c.UserContext(), filemanager.CreateFileFromUploadIntentParams{ UploadIntentID: uploadIntentID, FolderID: folderID, FolderProvided: folderProvided, Name: fileName, ActorID: actorUserID(c), IsTemplate: true, }) if createErr != nil { if errors.Is(createErr, filemanager.ErrDuplicateName) { reusedExistingFile = true targetFolderID := folderID if !folderProvided || len(targetFolderID) == 0 { root, rootErr := h.fileManager.GetOrCreateSystemRootFolder(c.UserContext()) if rootErr != nil || root == nil { detail := "unable to resolve target folder" if rootErr != nil { detail = rootErr.Error() } writeHelicopterFileBadRequestError(c, "Create failed", detail) return nil } targetFolderID = root.ID } nameNormalized := strings.ToLower(filepath.Base(strings.TrimSpace(fileName))) if nameNormalized == "" { writeHelicopterFileValidationError(c, "file_name is invalid", "/data/attributes/file_name") return nil } existingFile, existingErr := h.fileManager.GetFileByFolderAndName(c.UserContext(), targetFolderID, nameNormalized) if existingErr != nil || existingFile == nil { detail := "duplicate file exists but cannot be resolved" if existingErr != nil { detail = existingErr.Error() } writeHelicopterFileBadRequestError(c, "Create failed", detail) return nil } createdFile = existingFile } else { writeHelicopterFileBadRequestError(c, "Create failed", createErr.Error()) return nil } } if createdFile == nil { writeHelicopterFileBadRequestError(c, "Create failed", "unable to create file from upload intent") return nil } if err := h.validateStoredHelicopterFileContent(c.UserContext(), createdFile); err != nil { // Freshly uploaded corrupt files are removed; a reused existing template is left // intact (it may be referenced elsewhere) and only reported. if !reusedExistingFile { _ = h.fileManager.DeleteFile(c.UserContext(), createdFile.ID, actorUserID(c), nil) } return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "uploaded file content is corrupt or unsupported", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/upload_intent_uuid"}, }}) } row := &helicopterfile.HelicopterFile{ Section: section, Position: intOrDefault(attrs.Position, 0), IsMandatory: boolOrDefault(attrs.IsMandatory, false), HelicopterID: helicopterID, SourceFileID: append([]byte(nil), createdFile.ID...), CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c), } if err := h.svc.Create(c.UserContext(), row); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Create failed", Detail: safeInternalDetail(), }}) } h.publishHelicopterFileCreated(actorUserID(c), row, createdFile) createdRow, getErr := h.svc.GetByID(c.UserContext(), row.ID) if getErr == nil && createdRow != nil { return response.Write(c, fiber.StatusCreated, helicopterFileResource(c.UserContext(), h.storage, createdRow)) } return response.Write(c, fiber.StatusCreated, helicopterFileResource(c.UserContext(), h.storage, row)) } func (h *HelicopterFileHandler) createBulk(c *fiber.Ctx) error { var req requestdto.HelicopterFileCreateBulkRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if len(req.Data) == 0 { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "data must contain at least one item", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } if len(req.Data) > helicopterFileBulkMaxItems { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "too many items in one request", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } inputs := make([]helicopterfile.CreateBulkInput, len(req.Data)) for i := range req.Data { item := req.Data[i] inputs[i] = helicopterfile.CreateBulkInput{ Type: strings.TrimSpace(item.Type), Section: strings.TrimSpace(item.Attributes.Section), Position: item.Attributes.Position, IsMandatory: item.Attributes.IsMandatory, HelicopterID: strings.TrimSpace(item.Attributes.HelicopterID), UploadIntentUUID: strings.TrimSpace(item.Attributes.UploadIntentUUID), FileName: strings.TrimSpace(item.Attributes.FileName), FolderUUID: item.Attributes.FolderUUID, } } if h.fileManager != nil { rootCache := make(map[string][]byte) for i := range inputs { if inputs[i].FolderUUID != nil { continue } rootKey := "root" rootID, ok := rootCache[rootKey] if !ok { root, rootErr := h.fileManager.GetOrCreateSystemRootFolder(c.UserContext()) if rootErr != nil || root == nil || len(root.ID) != 16 { continue } rootID = append([]byte(nil), root.ID...) rootCache[rootKey] = rootID } folderStr, strErr := uuidv7.BytesToString(rootID) if strErr != nil { continue } inputs[i].FolderUUID = &folderStr } } results := h.svc.CreateFromUploadIntentsBulk(c.UserContext(), inputs, actorUserID(c)) items := make([]responsedto.HelicopterFileCreateBulkItem, len(results)) for i := range results { result := results[i] if result.Success && result.Row != nil { if err := h.validateStoredHelicopterFileContent(c.UserContext(), result.Row.SourceFile); err != nil { // Reject corrupt uploads: drop the helicopter file row we just created and // report the item as failed. The now-unreferenced file is reclaimed by GC. _ = h.svc.Delete(c.UserContext(), result.Row.ID) items[i] = helicopterFileCreateBulkErrorItem( result.Index, fiber.StatusUnprocessableEntity, "Validation error", "uploaded file content is corrupt or unsupported", ) continue } h.publishHelicopterFileCreated(actorUserID(c), result.Row, result.Row.SourceFile) res := helicopterFileResource(c.UserContext(), h.storage, result.Row) items[i] = responsedto.HelicopterFileCreateBulkItem{ Index: result.Index, Success: true, Resource: &res, } continue } errStatus := fiber.StatusInternalServerError errTitle := "Internal error" errDetail := "missing item error" if result.Error != nil { errStatus = result.Error.Status errTitle = result.Error.Title errDetail = result.Error.Detail } items[i] = helicopterFileCreateBulkErrorItem(result.Index, errStatus, errTitle, errDetail) } return writeHelicopterFileCreateBulkResponse(c, items) } // UpdateHelicopterFile godoc // @Summary Update helicopter file // @Description Update a helicopter file template record. // @Description Section allowed values: `bfi` (Before First Flight Inspection), `fpwb` (Flight Preparation and WB), `afi` (After Last Flight Inspection). // @Description Optional attributes: `section`, `position`, `is_required`, `helicopter_id`, `file_name`. // @Tags Helicopter - Master File // @Accept json // @Produce json // @Param request body requestdto.HelicopterFileUpdateBulkRequest true "JSON:API helicopter file update request (single object or bulk array)" // @Success 200 {object} responsedto.HelicopterFileResponse // @Success 207 {object} responsedto.HelicopterFileUpdateBulkResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopter-files/update [patch] func (h *HelicopterFileHandler) Update(c *fiber.Ctx) error { isBulk, err := isJSONAPIDataArrayPayload(c.Body()) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if isBulk { return h.updateBulk(c) } var req requestdto.HelicopterFileUpdateRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, errs) } resource, errItem := h.updateOne(c, req.Data) if errItem != nil { return writeHelicopterFileErrors(c, errItem.Status, []jsonapi.ErrorObject{{ Status: strconv.Itoa(errItem.Status), Title: errItem.Title, Detail: errItem.Detail, }}) } return response.Write(c, fiber.StatusOK, *resource) } func (h *HelicopterFileHandler) updateBulk(c *fiber.Ctx) error { var req requestdto.HelicopterFileUpdateBulkRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if len(req.Data) == 0 { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "data must contain at least one item", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } if len(req.Data) > helicopterFileBulkMaxItems { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "too many items in one request", Source: &jsonapi.ErrorSource{Pointer: "/data"}, }}) } items := make([]responsedto.HelicopterFileUpdateBulkItem, len(req.Data)) for i := range req.Data { itemReq := requestdto.HelicopterFileUpdateRequest{Data: req.Data[i]} if errs := h.validate.ValidateStruct(itemReq); errs != nil { detail := "invalid request item" if len(errs) > 0 && strings.TrimSpace(errs[0].Detail) != "" { detail = errs[0].Detail } items[i] = helicopterFileUpdateBulkErrorItem(i, fiber.StatusUnprocessableEntity, "Validation error", detail) continue } resource, errItem := h.updateOne(c, req.Data[i]) if errItem != nil { items[i] = helicopterFileUpdateBulkErrorItem(i, errItem.Status, errItem.Title, errItem.Detail) continue } items[i] = responsedto.HelicopterFileUpdateBulkItem{ Index: i, Success: true, Resource: resource, } } return writeHelicopterFileUpdateBulkResponse(c, items) } func (h *HelicopterFileHandler) updateOne(c *fiber.Ctx, data requestdto.HelicopterFileUpdateData) (*jsonapi.Resource, *responsedto.HelicopterFileBulkError) { id, err := uuidv7.ParseString(strings.TrimSpace(data.ID)) if err != nil { return nil, helicopterFileBulkErrorFromDef(fiber.StatusUnprocessableEntity, "Validation error", "id is invalid UUID") } existing, err := h.svc.GetByID(c.UserContext(), id) if err != nil { return nil, helicopterFileBulkErrorFromError("Get failed", err) } if existing == nil { return nil, helicopterFileBulkErrorFromDef(fiber.StatusNotFound, "Not found", "helicopter file not found") } prevAttachmentID := append([]byte(nil), existing.FileAttachmentID...) if data.Attributes.Section != nil { section := strings.TrimSpace(*data.Attributes.Section) if !isValidHelicopterFileSection(section) { return nil, helicopterFileBulkErrorFromDef(fiber.StatusUnprocessableEntity, "Validation error", "section is invalid") } existing.Section = section } if data.Attributes.Position != nil { existing.Position = *data.Attributes.Position } if data.Attributes.IsMandatory != nil { existing.IsMandatory = *data.Attributes.IsMandatory } helicopterIDRaw := strings.TrimSpace(optionalStringValue(data.Attributes.HelicopterID)) if helicopterIDRaw != "" { helicopterID, parseErr := uuidv7.ParseString(helicopterIDRaw) if parseErr != nil { return nil, helicopterFileBulkErrorFromDef(fiber.StatusUnprocessableEntity, "Validation error", "helicopter_id is invalid UUID") } exists, existsErr := h.svc.HelicopterExists(c.UserContext(), helicopterID) if existsErr != nil { return nil, helicopterFileBulkErrorFromError("Update failed", existsErr) } if !exists { return nil, helicopterFileBulkErrorFromDef(fiber.StatusUnprocessableEntity, "Validation error", "helicopter_id not found") } existing.HelicopterID = helicopterID } if data.Attributes.FileName != nil { fileName := strings.TrimSpace(*data.Attributes.FileName) if fileName == "" { return nil, helicopterFileBulkErrorFromDef(fiber.StatusUnprocessableEntity, "Validation error", "file_name is required") } if h.fileManager == nil { return nil, helicopterFileBulkErrorFromDef(fiber.StatusBadRequest, "Update failed", "file manager service is not configured") } fileID := append([]byte(nil), existing.SourceFileID...) if len(fileID) != 16 { attachment, attachmentErr := h.fileManager.GetAttachmentByID(c.UserContext(), existing.FileAttachmentID) if attachmentErr != nil { return nil, helicopterFileBulkErrorFromError("Update failed", attachmentErr) } if attachment == nil || len(attachment.FileID) == 0 { return nil, helicopterFileBulkErrorFromDef(fiber.StatusNotFound, "Not found", "file attachment not found") } fileID = attachment.FileID } if _, moveErr := h.fileManager.MoveFileNode(c.UserContext(), fileID, filemanager.MoveFileParams{ Name: &fileName, ActorID: actorUserID(c), }); moveErr != nil { return nil, helicopterFileBulkErrorFromError("Update failed", moveErr) } } existing.UpdatedBy = actorUserID(c) if err := h.svc.Update(c.UserContext(), existing); err != nil { return nil, helicopterFileBulkErrorFromError("Update failed", err) } if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, prevAttachmentID, existing.FileAttachmentID, actorUserID(c)); err != nil { return nil, helicopterFileBulkErrorFromError("Update failed", err) } updatedRow, getErr := h.svc.GetByID(c.UserContext(), existing.ID) if getErr == nil && updatedRow != nil { res := helicopterFileResource(c.UserContext(), h.storage, updatedRow) return &res, nil } res := helicopterFileResource(c.UserContext(), h.storage, existing) return &res, nil } func helicopterFileBulkErrorFromDef(status int, title, detail string) *responsedto.HelicopterFileBulkError { def := inferHelicopterFileDef(status, title, detail) return &responsedto.HelicopterFileBulkError{ Status: status, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, } } func helicopterFileBulkErrorFromError(title string, err error) *responsedto.HelicopterFileBulkError { def := inferHelicopterFileDef(fiber.StatusBadRequest, title, "") if errors.Is(err, filemanager.ErrFileNotFound) { def = inferHelicopterFileDef(fiber.StatusNotFound, title, "not found") } if errors.Is(err, filemanager.ErrDuplicateName) { def = inferHelicopterFileDef(fiber.StatusUnprocessableEntity, "Validation error", "duplicate name") } return &responsedto.HelicopterFileBulkError{ Status: def.Status, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, } } // GetHelicopterFile godoc // @Summary Get helicopter files by helicopter ID // @Description Return helicopter file template collection for selected helicopter // @Tags Helicopter - Master File // @Produce json // @Param helicopter_id path string true "Helicopter ID" // @Success 200 {object} responsedto.HelicopterFileCollectionResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopter-files/get/{helicopter_id} [get] func (h *HelicopterFileHandler) Get(c *fiber.Ctx) error { helicopterID := strings.TrimSpace(c.Params("helicopter_id")) id, err := uuidv7.ParseString(helicopterID) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "helicopter_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/helicopter_id"}, }}) } exists, err := h.svc.HelicopterExists(c.UserContext(), id) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Get failed", Detail: safeInternalDetail(), }}) } if !exists { return writeHelicopterFileErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "helicopter not found", }}) } rows, err := h.svc.ListByHelicopter(c.UserContext(), id) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Get failed", Detail: safeInternalDetail(), }}) } rows = h.filterVisibleHelicopterFileRows(c.UserContext(), actorUserID(c), rows) groupedFiles := responsedto.HelicopterFileCollectionSections{ Before: make([]responsedto.HelicopterFileCollectionItem, 0), Prepare: make([]responsedto.HelicopterFileCollectionItem, 0), After: make([]responsedto.HelicopterFileCollectionItem, 0), } for i := range rows { item := helicopterFileCollectionItem(c.UserContext(), h.storage, &rows[i]) switch strings.TrimSpace(rows[i].Section) { case helicopterfile.SectionBeforeFirstFlightInspection: groupedFiles.Before = append(groupedFiles.Before, item) case helicopterfile.SectionFlightPreparationAndWB: groupedFiles.Prepare = append(groupedFiles.Prepare, item) case helicopterfile.SectionAfterLastFlightInspection: groupedFiles.After = append(groupedFiles.After, item) } } helicopterSummary := responsedto.HelicopterFileCollectionHelicopter{ ID: idString(id), } if len(rows) > 0 && rows[0].Helicopter != nil { helicopterSummary.Name = strings.TrimSpace(rows[0].Helicopter.Designation) } else if h.helicopter != nil { if helicopterRow, heliErr := h.helicopter.GetByID(c.UserContext(), id); heliErr == nil && helicopterRow != nil { helicopterSummary.Name = strings.TrimSpace(helicopterRow.Designation) } } return response.Write(c, fiber.StatusOK, responsedto.HelicopterFileCollectionResponse{ Type: "helicopter_file_collection", Attributes: responsedto.HelicopterFileCollectionAttributes{ Helicopter: helicopterSummary, Files: groupedFiles, }, }) } // ListHelicopterFiles godoc // @Summary List helicopter files // @Description List helicopter file template records grouped by helicopter. // @Description Pagination is applied per helicopter group (not per file row), so one helicopter's files stay in the same page. // @Tags Helicopter - Master File // @Produce json // @Param filter[search] query string false "Search text" // @Param search query string false "Search text (fallback)" // @Param sort query string false "Sort field" // @Param page[number] query int false "Page number" // @Param page[size] query int false "Page size" // @Param page query int false "Page number (fallback)" // @Param size query int false "Page size (fallback)" // @Success 200 {object} responsedto.HelicopterFileCollectionListResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopter-files/get-all [get] func (h *HelicopterFileHandler) List(c *fiber.Ctx) error { filter := c.Query("filter[search]") if filter == "" { filter = c.Query("search") } pageNumber := parseIntDefault(c.Query("page[number]"), 0) if pageNumber <= 0 { pageNumber = parseIntDefault(c.Query("page"), 1) } pageSize := parseIntDefault(c.Query("page[size]"), 0) if pageSize <= 0 { pageSize = parseIntDefault(c.Query("size"), 20) } if pageSize > 100 { pageSize = 100 } if pageSize < 1 { pageSize = 20 } if pageNumber < 1 { pageNumber = 1 } sort := c.Query("sort") allRows, _, err := h.svc.List(c.UserContext(), filter, sort, 0, 0) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } allRows = h.filterVisibleHelicopterFileRows(c.UserContext(), actorUserID(c), allRows) grouped := make([]responsedto.HelicopterFileCollectionResponse, 0) indexByHelicopterID := make(map[string]int) for i := range allRows { helicopterID := idString(allRows[i].HelicopterID) idx, ok := indexByHelicopterID[helicopterID] if !ok { helicopterName := "" if allRows[i].Helicopter != nil { helicopterName = strings.TrimSpace(allRows[i].Helicopter.Designation) } grouped = append(grouped, responsedto.HelicopterFileCollectionResponse{ Type: "helicopter_file_collection", Attributes: responsedto.HelicopterFileCollectionAttributes{ Helicopter: responsedto.HelicopterFileCollectionHelicopter{ ID: helicopterID, Name: helicopterName, }, Files: responsedto.HelicopterFileCollectionSections{ Before: make([]responsedto.HelicopterFileCollectionItem, 0), Prepare: make([]responsedto.HelicopterFileCollectionItem, 0), After: make([]responsedto.HelicopterFileCollectionItem, 0), }, }, }) idx = len(grouped) - 1 indexByHelicopterID[helicopterID] = idx } item := helicopterFileCollectionItem(c.UserContext(), h.storage, &allRows[i]) switch strings.TrimSpace(allRows[i].Section) { case helicopterfile.SectionBeforeFirstFlightInspection: grouped[idx].Attributes.Files.Before = append(grouped[idx].Attributes.Files.Before, item) case helicopterfile.SectionFlightPreparationAndWB: grouped[idx].Attributes.Files.Prepare = append(grouped[idx].Attributes.Files.Prepare, item) case helicopterfile.SectionAfterLastFlightInspection: grouped[idx].Attributes.Files.After = append(grouped[idx].Attributes.Files.After, item) } } totalGroups := len(grouped) start := (pageNumber - 1) * pageSize if start > totalGroups { start = totalGroups } end := start + pageSize if end > totalGroups { end = totalGroups } paged := make([]responsedto.HelicopterFileCollectionResponse, 0) if start < end { paged = grouped[start:end] } return response.WriteWithMeta(c, fiber.StatusOK, paged, map[string]any{ "page_number": pageNumber, "page_size": pageSize, "total": totalGroups, }) } // DeleteHelicopterFile godoc // @Summary Delete helicopter file // @Description Delete helicopter file template record // @Tags Helicopter - Master File // @Accept json // @Produce json // @Param request body requestdto.HelicopterFileDeleteRequest true "JSON:API helicopter file delete request" // @Success 200 {object} responsedto.HelicopterFileDeleteResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopter-files/delete [delete] func (h *HelicopterFileHandler) Delete(c *fiber.Ctx) error { var req requestdto.HelicopterFileDeleteRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, errs) } id, err := uuidv7.ParseString(strings.TrimSpace(req.Data.ID)) if err != nil { return writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } existing, _ := h.svc.GetByID(c.UserContext(), id) var prevAttachmentID []byte var prevSourceFileID []byte if existing != nil { prevAttachmentID = append([]byte(nil), existing.FileAttachmentID...) prevSourceFileID = append([]byte(nil), existing.SourceFileID...) } if err := h.svc.Delete(c.UserContext(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return writeHelicopterFileErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "helicopter file not found", }}) } return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Delete failed", Detail: safeInternalDetail(), }}) } if len(prevAttachmentID) == 16 { if err := syncAttachmentLifecycleClear(c.UserContext(), h.fileManager, prevAttachmentID, actorUserID(c)); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Delete failed", Detail: safeInternalDetail(), }}) } } if len(prevAttachmentID) != 16 && len(prevSourceFileID) == 16 { if err := syncFileLifecycleSwap(c.UserContext(), h.fileManager, prevSourceFileID, nil, actorUserID(c)); err != nil { return writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Delete failed", Detail: safeInternalDetail(), }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "helicopter_file_delete", Attributes: map[string]any{ "deleted": true, }, }) } func helicopterFileResource( ctx context.Context, storage fileManagerDownloadURLStorage, row *helicopterfile.HelicopterFile, ) jsonapi.Resource { return jsonapi.Resource{ Type: "helicopter_file", ID: idString(row.ID), Attributes: helicopterFileItemAttributesMap(ctx, storage, row), } } func (h *HelicopterFileHandler) publishHelicopterFileCreated(actorID []byte, row *helicopterfile.HelicopterFile, sourceFile *filemanager.File) { if h == nil || h.helicopterFileEvents == nil || row == nil || len(actorID) != 16 || len(row.ID) != 16 || len(row.HelicopterID) != 16 { return } if sourceFile == nil { sourceFile = row.SourceFile } if sourceFile == nil { return } actorUUID, err := uuidv7.BytesToString(actorID) if err != nil || strings.TrimSpace(actorUUID) == "" { return } helicopterUUID, err := uuidv7.BytesToString(row.HelicopterID) if err != nil || strings.TrimSpace(helicopterUUID) == "" { return } updatedAt := row.UpdatedAt.UTC() if updatedAt.IsZero() { updatedAt = time.Now().UTC() } event := helicopterfilerealtime.Event{ EventType: helicopterfilerealtime.DefaultEventType, HelicopterID: helicopterUUID, HelicopterFileID: idString(row.ID), FileID: idString(row.SourceFileID), Section: strings.TrimSpace(row.Section), Name: strings.TrimSpace(sourceFile.Name), MimeType: strings.TrimSpace(sourceFile.MimeType), AttachmentPending: row != nil && len(row.FileAttachmentID) != 16 && len(row.SourceFileID) == 16, Position: row.Position, IsMandatory: row.IsMandatory, UpdatedAt: updatedAt.Format(time.RFC3339), } h.helicopterFileEvents.PublishToUsers([]string{actorUUID}, event) } func helicopterFileItemAttributesMap( ctx context.Context, storage fileManagerDownloadURLStorage, row *helicopterfile.HelicopterFile, ) map[string]any { fileName := "" downloadURL := "" helicopterName := "" if row != nil && row.Helicopter != nil { helicopterName = strings.TrimSpace(row.Helicopter.Designation) } if row != nil && row.FileAttachment != nil && row.FileAttachment.File != nil { fileName = strings.TrimSpace(row.FileAttachment.File.Name) downloadURL = strings.TrimSpace(resolveFileManagerDownloadURL(ctx, storage, row.FileAttachment.File)) } attachmentPending := row != nil && len(row.FileAttachmentID) != 16 && len(row.SourceFileID) == 16 isTemplate := helicopterFileIsTemplate(row) attachmentID := idString(row.FileAttachmentID) if attachmentPending { attachmentID = idString(row.SourceFileID) if fileName == "" && row.SourceFile != nil { fileName = strings.TrimSpace(row.SourceFile.Name) } } return map[string]any{ "section": strings.TrimSpace(row.Section), "position": row.Position, "is_mandatory": row.IsMandatory, "is_template": isTemplate, "helicopter": map[string]any{ "id": idString(row.HelicopterID), "name": helicopterName, }, "attachment_pending": attachmentPending, "attachment": map[string]any{ "id": attachmentID, "file_name": fileName, "download_url": downloadURL, }, "created_at": row.CreatedAt.UTC().Format(time.RFC3339), "created_by": uuidStringOrEmpty(row.CreatedBy), "updated_at": row.UpdatedAt.UTC().Format(time.RFC3339), "updated_by": uuidStringOrEmpty(row.UpdatedBy), } } func (h *HelicopterFileHandler) filterVisibleHelicopterFileRows(ctx context.Context, actorID []byte, rows []helicopterfile.HelicopterFile) []helicopterfile.HelicopterFile { if len(rows) == 0 { return rows } filtered := make([]helicopterfile.HelicopterFile, 0, len(rows)) for i := range rows { // Documents created from a template (e.g. via /files/create-from-template) are // persisted as helicopter_files rows for takeover/WOPI use, but must not surface // in the helicopter files listing. if helicopterFileCreatedFromTemplate(&rows[i]) { continue } if h.canViewHelicopterFileRow(ctx, actorID, &rows[i]) { filtered = append(filtered, rows[i]) } } return filtered } func (h *HelicopterFileHandler) canViewHelicopterFileRow(ctx context.Context, actorID []byte, row *helicopterfile.HelicopterFile) bool { if row == nil { return false } takeoverID := helicopterFileTakeoverID(row) if len(takeoverID) != 16 { return true } if h == nil || h.takeover == nil || len(actorID) != 16 { return false } takeoverRow, err := h.takeover.GetByID(ctx, takeoverID) if err != nil || takeoverRow == nil { return false } return takeoverHasUserAccess(takeoverRow, actorID) } func helicopterFileTakeoverID(row *helicopterfile.HelicopterFile) []byte { if row == nil { return nil } if row.SourceFile != nil && len(row.SourceFile.TakeoverID) == 16 { return row.SourceFile.TakeoverID } if row.FileAttachment != nil && row.FileAttachment.File != nil && len(row.FileAttachment.File.TakeoverID) == 16 { return row.FileAttachment.File.TakeoverID } return nil } func helicopterFileCollectionItem( ctx context.Context, storage fileManagerDownloadURLStorage, row *helicopterfile.HelicopterFile, ) responsedto.HelicopterFileCollectionItem { fileName := "" downloadURL := "" if row != nil && row.FileAttachment != nil && row.FileAttachment.File != nil { fileName = strings.TrimSpace(row.FileAttachment.File.Name) downloadURL = strings.TrimSpace(resolveFileManagerDownloadURL(ctx, storage, row.FileAttachment.File)) } attachmentPending := row != nil && len(row.FileAttachmentID) != 16 && len(row.SourceFileID) == 16 isTemplate := helicopterFileIsTemplate(row) attachmentID := idString(row.FileAttachmentID) if attachmentPending { attachmentID = idString(row.SourceFileID) if fileName == "" && row.SourceFile != nil { fileName = strings.TrimSpace(row.SourceFile.Name) } } return responsedto.HelicopterFileCollectionItem{ HelicopterFileID: idString(row.ID), Section: strings.TrimSpace(row.Section), Position: row.Position, IsMandatory: row.IsMandatory, IsTemplate: isTemplate, AttachmentPending: attachmentPending, Attachment: responsedto.HelicopterFileCollectionAttachment{ ID: attachmentID, FileName: fileName, DownloadURL: downloadURL, }, CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), CreatedBy: uuidStringOrEmpty(row.CreatedBy), UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339), UpdatedBy: uuidStringOrEmpty(row.UpdatedBy), } } func helicopterFileIsTemplate(row *helicopterfile.HelicopterFile) bool { if row == nil { return false } if row.SourceFile != nil { return row.SourceFile.IsTemplate } if row.FileAttachment != nil && row.FileAttachment.File != nil { return row.FileAttachment.File.IsTemplate } return false } // helicopterFileCreatedFromTemplate reports whether the row points to a document // instantiated from a template via the WOPI create-from-template flow (identified by // a non-empty template_uuid on the underlying file). These rows stay in the database // for takeover/WOPI use but are excluded from the helicopter files listing, which only // surfaces templates and regular files — not template-derived working copies. // officeEditableExtensions are the file types Collabora edits and whose byte-level // integrity must hold before they can be registered as templates. var officeEditableExtensions = map[string]struct{}{ "docx": {}, "xlsx": {}, "pptx": {}, "odt": {}, "ods": {}, "odp": {}, "doc": {}, "xls": {}, "ppt": {}, "rtf": {}, } // validateStoredHelicopterFileContent rejects office documents whose stored bytes are // corrupt or incomplete, so they can never be registered as templates and later surface // as corrupt Collabora sessions on create-from-template. Non-office files pass through. func (h *HelicopterFileHandler) validateStoredHelicopterFileContent(ctx context.Context, file *filemanager.File) error { if file == nil { return nil } ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(file.Name)), ".")) if ext == "" { ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(file.ObjectKey)), ".")) } if _, ok := officeEditableExtensions[ext]; !ok { return nil } storage, ok := any(h.storage).(templateCopyStorage) if !ok { return nil } body, meta, err := storage.GetObject(ctx, file.ObjectKey) if err != nil { return err } defer body.Close() payload, err := io.ReadAll(body) if err != nil { return err } declaredMime := firstNonEmpty(meta.ContentType, file.MimeType) if ext == "xlsx" { if sanitizeStorage, ok := any(h.storage).(xlsxSanitizerStorage); ok { sanitizedPayload, _, changed, err := sanitizeXLSXPayload(payload) if err != nil { return err } if changed { contentType := strings.TrimSpace(firstNonEmpty(declaredMime, canonicalOfficeContentType(file.Name))) if contentType == "" { contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } newMeta, err := sanitizeStorage.PutObject(ctx, filemanager.PutObjectInput{ ObjectKey: file.ObjectKey, ContentType: contentType, SizeBytes: int64(len(sanitizedPayload)), Body: bytes.NewReader(sanitizedPayload), }) if err != nil { return err } file.SizeBytes = int64(len(sanitizedPayload)) file.MimeType = contentType if strings.TrimSpace(newMeta.Bucket) != "" { file.Bucket = newMeta.Bucket } if strings.TrimSpace(newMeta.ObjectKey) != "" { file.ObjectKey = newMeta.ObjectKey } if strings.TrimSpace(newMeta.ETag) != "" { file.ETag = newMeta.ETag } if strings.TrimSpace(newMeta.VersionID) != "" { file.ObjectVersion = newMeta.VersionID } if updater := h.fileManager; updater != nil { if err := updater.UpdateFile(ctx, file); err != nil { return err } } } payload = sanitizedPayload } } if err := validateOfficeContentSample(ext, declaredMime, payload); err != nil { return err } switch ext { case "docx", "xlsx", "pptx", "odt", "ods", "odp": return validateOfficeZipStructure(ext, payload) default: return nil } } func helicopterFileCreatedFromTemplate(row *helicopterfile.HelicopterFile) bool { if row == nil { return false } if row.SourceFile != nil { return len(row.SourceFile.TemplateUUID) == 16 } if row.FileAttachment != nil && row.FileAttachment.File != nil { return len(row.FileAttachment.File.TemplateUUID) == 16 } return false } func writeHelicopterFileErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error { enriched := make([]jsonapi.ErrorObject, 0, len(errs)) statusStr := strconv.Itoa(status) for i := range errs { e := errs[i] e.Status = statusStr def := inferHelicopterFileDef(status, e.Title, e.Detail) if strings.TrimSpace(e.Code) == "" { e.Code = def.Code } if strings.TrimSpace(e.ErrorCode) == "" { e.ErrorCode = def.ErrorCode } if strings.TrimSpace(e.Title) == "" { e.Title = def.Title } e.Detail = def.Message enriched = append(enriched, e) } return response.WriteErrors(c, status, enriched) } func inferHelicopterFileDef(status int, title, detail string) apperrorsx.Def { lowerTitle := strings.ToLower(strings.TrimSpace(title)) lowerDetail := strings.ToLower(strings.TrimSpace(detail)) switch status { case fiber.StatusNotFound: return apperrorsx.ErrHelicopterFileNotFound case fiber.StatusUnprocessableEntity: switch { case strings.Contains(lowerDetail, "invalid uuid"): return apperrorsx.ErrHelicopterFileInvalidUUID case strings.Contains(lowerDetail, "section is invalid"): return apperrorsx.ErrHelicopterFileSectionInvalid case strings.Contains(lowerDetail, "file_name is required"): return apperrorsx.ErrHelicopterFileFileNameRequired default: return apperrorsx.ErrHelicopterFileInvalidPayload } case fiber.StatusBadRequest: switch { case strings.Contains(lowerTitle, "invalid json"): return apperrorsx.ErrHelicopterFileInvalidJSON case strings.Contains(lowerTitle, "create failed"), strings.Contains(lowerTitle, "upload"), strings.Contains(lowerTitle, "cancel upload"): return apperrorsx.ErrHelicopterFileCreateFailed case strings.Contains(lowerTitle, "update failed"): return apperrorsx.ErrHelicopterFileUpdateFailed case strings.Contains(lowerTitle, "delete failed"): return apperrorsx.ErrHelicopterFileDeleteFailed case strings.Contains(lowerTitle, "get failed"): return apperrorsx.ErrHelicopterFileGetFailed case strings.Contains(lowerTitle, "list failed"): return apperrorsx.ErrHelicopterFileListFailed default: if strings.Contains(lowerDetail, "get failed") { return apperrorsx.ErrHelicopterFileGetFailed } return apperrorsx.ErrHelicopterFileInvalidPayload } default: return apperrorsx.ErrInternal } } func inferHelicopterFileCode(status int, title, detail string) (string, string) { def := inferHelicopterFileDef(status, title, detail) return def.Code, def.ErrorCode } func writeHelicopterFileBadRequestError(c *fiber.Ctx, title, detail string) { _ = writeHelicopterFileErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: title, Detail: detail, }}) } func writeHelicopterFileValidationError(c *fiber.Ctx, detail, pointer string) { _ = writeHelicopterFileErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: detail, Source: &jsonapi.ErrorSource{ Pointer: pointer, }, }}) } func isValidHelicopterFileSection(section string) bool { switch strings.TrimSpace(section) { case helicopterfile.SectionBeforeFirstFlightInspection, helicopterfile.SectionFlightPreparationAndWB, helicopterfile.SectionAfterLastFlightInspection: return true default: return false } } func optionalStringValue(v *string) string { if v == nil { return "" } return *v } func helicopterFileUploadBulkErrorItem(index int, status int, title, detail string) responsedto.HelicopterFileUploadIntentBulkItem { def := inferHelicopterFileDef(status, title, detail) return responsedto.HelicopterFileUploadIntentBulkItem{ Index: index, Success: false, Error: &responsedto.HelicopterFileBulkError{ Status: status, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, }, } } func helicopterFileCreateBulkErrorItem(index int, status int, title, detail string) responsedto.HelicopterFileCreateBulkItem { def := inferHelicopterFileDef(status, title, detail) return responsedto.HelicopterFileCreateBulkItem{ Index: index, Success: false, Error: &responsedto.HelicopterFileBulkError{ Status: status, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, }, } } func helicopterFileUpdateBulkErrorItem(index int, status int, title, detail string) responsedto.HelicopterFileUpdateBulkItem { def := inferHelicopterFileDef(status, title, detail) return responsedto.HelicopterFileUpdateBulkItem{ Index: index, Success: false, Error: &responsedto.HelicopterFileBulkError{ Status: status, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, }, } } func writeHelicopterFileUploadIntentBulkResponse(c *fiber.Ctx, items []responsedto.HelicopterFileUploadIntentBulkItem) error { total := len(items) success := 0 for i := range items { if items[i].Success { success++ items[i].Error = nil continue } items[i].Resource = nil if items[i].Error == nil { def := inferHelicopterFileDef(fiber.StatusInternalServerError, "Internal error", "missing item error") items[i].Error = &responsedto.HelicopterFileBulkError{ Status: fiber.StatusInternalServerError, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, } } } failed := total - success meta := map[string]any{ "total": total, "success": success, "failed": failed, "partial_success": success > 0 && failed > 0, } var statusCode int if failed == 0 { statusCode = fiber.StatusCreated } else if success > 0 { statusCode = fiber.StatusMultiStatus } else { statusCode = fiber.StatusUnprocessableEntity for i := range items { if items[i].Error != nil && items[i].Error.Status >= 400 { statusCode = items[i].Error.Status break } } } return response.WriteWithMeta(c, statusCode, items, meta) } func writeHelicopterFileCreateBulkResponse(c *fiber.Ctx, items []responsedto.HelicopterFileCreateBulkItem) error { total := len(items) success := 0 for i := range items { if items[i].Success { success++ items[i].Error = nil continue } items[i].Resource = nil if items[i].Error == nil { def := inferHelicopterFileDef(fiber.StatusInternalServerError, "Internal error", "missing item error") items[i].Error = &responsedto.HelicopterFileBulkError{ Status: fiber.StatusInternalServerError, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, } } } failed := total - success meta := map[string]any{ "total": total, "success": success, "failed": failed, "partial_success": success > 0 && failed > 0, } var statusCode int if failed == 0 { statusCode = fiber.StatusCreated } else if success > 0 { statusCode = fiber.StatusMultiStatus } else { statusCode = fiber.StatusUnprocessableEntity for i := range items { if items[i].Error != nil && items[i].Error.Status >= 400 { statusCode = items[i].Error.Status break } } } return response.WriteWithMeta(c, statusCode, items, meta) } func helicopterSectionFolderName(section string) (string, bool) { switch strings.TrimSpace(section) { case helicopterfile.SectionBeforeFirstFlightInspection: return "Before First Flight Inspection", true case helicopterfile.SectionFlightPreparationAndWB: return "Flight Preparation and W&B", true case helicopterfile.SectionAfterLastFlightInspection: return "After Last Flight Inspection", true default: return "", false } } func (h *HelicopterFileHandler) ensureHelicopterSectionFolder(ctx context.Context, actorID, helicopterID []byte, section string) ([]byte, error) { folderName, ok := helicopterSectionFolderName(section) if !ok { return nil, nil } heli, err := h.helicopter.GetByID(ctx, helicopterID) if err != nil || heli == nil { return nil, err } designation := strings.TrimSpace(heli.Designation) if designation == "" { return nil, nil } root, err := h.fileManager.GetOrCreateSystemRootFolder(ctx) if err != nil || root == nil { return nil, err } aircraftFolderID, err := h.ensureHelicopterFolderByParentAndName(ctx, root.ID, "aircraft", actorID) if err != nil { return nil, err } designationFolderID, err := h.ensureHelicopterFolderByParentAndName(ctx, aircraftFolderID, designation, actorID) if err != nil { return nil, err } return h.ensureHelicopterFolderByParentAndName(ctx, designationFolderID, folderName, actorID) } func (h *HelicopterFileHandler) ensureHelicopterFolderByParentAndName(ctx context.Context, parentID []byte, name string, actorID []byte) ([]byte, error) { normalized := strings.ToLower(name) existing, err := h.fileManager.GetFolderByParentAndName(ctx, parentID, normalized) if err != nil { return nil, err } if existing != nil { return existing.ID, nil } created, err := h.fileManager.CreateFolderNode(ctx, filemanager.CreateFolderParams{ Name: name, ParentID: parentID, ActorID: actorID, }) if err != nil { return nil, err } return created.ID, nil } func writeHelicopterFileUpdateBulkResponse(c *fiber.Ctx, items []responsedto.HelicopterFileUpdateBulkItem) error { total := len(items) success := 0 for i := range items { if items[i].Success { success++ items[i].Error = nil continue } items[i].Resource = nil if items[i].Error == nil { def := inferHelicopterFileDef(fiber.StatusInternalServerError, "Internal error", "missing item error") items[i].Error = &responsedto.HelicopterFileBulkError{ Status: fiber.StatusInternalServerError, Code: def.Code, ErrorCode: def.ErrorCode, Title: def.Title, Detail: def.Message, } } } failed := total - success meta := map[string]any{ "total": total, "success": success, "failed": failed, "partial_success": success > 0 && failed > 0, } var statusCode int if failed == 0 { statusCode = fiber.StatusOK } else if success > 0 { statusCode = fiber.StatusMultiStatus } else { statusCode = fiber.StatusUnprocessableEntity for i := range items { if items[i].Error != nil && items[i].Error.Status >= 400 { statusCode = items[i].Error.Status break } } } return response.WriteWithMeta(c, statusCode, items, meta) }