Files
fm_be/internal/transport/http/handlers/helicopter_file_handler.go
2026-07-16 22:16:45 +07:00

1820 lines
62 KiB
Go

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)
}