1820 lines
62 KiB
Go
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)
|
|
}
|