init push
This commit is contained in:
603
internal/transport/http/handlers/easa_release_handler.go
Normal file
603
internal/transport/http/handlers/easa_release_handler.go
Normal file
@@ -0,0 +1,603 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
actionsignoff "wucher/internal/domain/action_signoff"
|
||||
complaint "wucher/internal/domain/complaint"
|
||||
contact "wucher/internal/domain/contact"
|
||||
easarelease "wucher/internal/domain/easa_release"
|
||||
fleethistory "wucher/internal/domain/fleet_history"
|
||||
"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"
|
||||
)
|
||||
|
||||
type EASAReleaseHandler struct {
|
||||
svc easarelease.Service
|
||||
complaintChecker complaintActionChecker
|
||||
signoffChecker easaSignoffChecker
|
||||
historySvc fleetHistoryRecorder
|
||||
contactSvc contactNameResolver
|
||||
aogRecalc aogRecalculator
|
||||
validate *validators.Validator
|
||||
}
|
||||
|
||||
// easaSignoffChecker resolves corrective-action sign-offs so the release can be gated
|
||||
// on them: a release requires the open complaint (via complaint_id) to be signed off,
|
||||
// or — when there is no open complaint — a standalone (fleet) sign-off.
|
||||
type easaSignoffChecker interface {
|
||||
GetByFlight(ctx context.Context, flightID []byte) (*actionsignoff.ActionSignoff, error)
|
||||
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*actionsignoff.ActionSignoff, error)
|
||||
// Unsign consumes the flight's standalone sign-off after a release, so the next
|
||||
// complaint-independent release requires a fresh signature (no stale sign-off).
|
||||
Unsign(ctx context.Context, flightID []byte, actor []byte) (*actionsignoff.ActionSignoff, error)
|
||||
}
|
||||
|
||||
// consumeStandaloneSignoff clears the fleet (complaint-independent) sign-off after a
|
||||
// release. No-op when unwired or when there is no standalone sign-off. Complaint-linked
|
||||
// sign-offs are not touched — they are spent when the complaint closes/reopens.
|
||||
func (h *EASAReleaseHandler) consumeStandaloneSignoff(ctx context.Context, flightID, actor []byte) {
|
||||
if h.signoffChecker == nil || len(flightID) != 16 {
|
||||
return
|
||||
}
|
||||
_, _ = h.signoffChecker.Unsign(ctx, flightID, actor)
|
||||
}
|
||||
|
||||
// contactNameResolver looks up a contact (the EASA signer) by id to resolve its name.
|
||||
type contactNameResolver interface {
|
||||
GetContactByID(ctx context.Context, userID []byte) (*contact.ContactListItem, error)
|
||||
}
|
||||
|
||||
func (h *EASAReleaseHandler) WithHistoryService(svc fleetHistoryRecorder) *EASAReleaseHandler {
|
||||
h.historySvc = svc
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *EASAReleaseHandler) WithContactResolver(svc contactNameResolver) *EASAReleaseHandler {
|
||||
h.contactSvc = svc
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *EASAReleaseHandler) signerNameAndShort(ctx context.Context, signerID *string) (name *string, short *string) {
|
||||
if h.contactSvc == nil || signerID == nil || strings.TrimSpace(*signerID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
idBytes, err := uuidv7.ParseString(strings.TrimSpace(*signerID))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
ct, err := h.contactSvc.GetContactByID(ctx, idBytes)
|
||||
if err != nil || ct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
n := strings.TrimSpace(strings.TrimSpace(ct.FirstName) + " " + strings.TrimSpace(ct.LastName))
|
||||
if n == "" {
|
||||
n = strings.TrimSpace(ct.ShortName)
|
||||
}
|
||||
if n != "" {
|
||||
name = &n
|
||||
}
|
||||
if s := strings.TrimSpace(ct.ShortName); s != "" {
|
||||
short = &s
|
||||
}
|
||||
return name, short
|
||||
}
|
||||
|
||||
type complaintActionChecker interface {
|
||||
ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error)
|
||||
MarkFixedByHelicopter(ctx context.Context, helicopterID []byte, easaID []byte, actor []byte) error
|
||||
MarkFixedByComplaint(ctx context.Context, complaintID []byte, easaID []byte, actor []byte) error
|
||||
MarkFixedByFlight(ctx context.Context, flightID []byte, easaID []byte, actor []byte) error
|
||||
}
|
||||
|
||||
// closeComplaintsForRelease closes the defect(s) the signed release resolves, at the
|
||||
// scope the release was created with (most specific first):
|
||||
// - complaint_id set → close only that complaint;
|
||||
// - else flight_id set → close every open complaint reported on that flight;
|
||||
// - else (standalone / whole-aircraft) → close all of the helicopter's open complaints.
|
||||
//
|
||||
// complaint_id and flight_id are mutually exclusive at create time, so at most one of
|
||||
// the first two branches applies. Then it recomputes the helicopter's AOG state.
|
||||
func (h *EASAReleaseHandler) closeComplaintsForRelease(ctx context.Context, row *easarelease.EASARelease, actor []byte) {
|
||||
if h.complaintChecker == nil || row == nil {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case len(row.ComplaintID) == 16:
|
||||
_ = h.complaintChecker.MarkFixedByComplaint(ctx, row.ComplaintID, row.ID, actor)
|
||||
case len(row.FlightID) == 16:
|
||||
_ = h.complaintChecker.MarkFixedByFlight(ctx, row.FlightID, row.ID, actor)
|
||||
default:
|
||||
_ = h.complaintChecker.MarkFixedByHelicopter(ctx, row.HelicopterID, row.ID, actor)
|
||||
}
|
||||
if h.aogRecalc != nil {
|
||||
_ = h.aogRecalc.RecalculateAOGByHelicopter(ctx, row.HelicopterID)
|
||||
}
|
||||
}
|
||||
|
||||
func NewEASAReleaseHandler(svc easarelease.Service) *EASAReleaseHandler {
|
||||
return &EASAReleaseHandler{svc: svc, validate: validators.New()}
|
||||
}
|
||||
|
||||
func (h *EASAReleaseHandler) WithComplaintActionChecker(checker complaintActionChecker) *EASAReleaseHandler {
|
||||
h.complaintChecker = checker
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *EASAReleaseHandler) WithActionSignoffChecker(checker easaSignoffChecker) *EASAReleaseHandler {
|
||||
h.signoffChecker = checker
|
||||
return h
|
||||
}
|
||||
|
||||
// requireActionSignoff gates a release on the corrective-action sign-off: when the
|
||||
// helicopter has open (non-NSR) complaints, each must have a signed sign-off (linked by
|
||||
// complaint_id); when there are none, a standalone (fleet) sign-off must be signed.
|
||||
// Returns nil (gate passes) when the checkers are not wired.
|
||||
func (h *EASAReleaseHandler) requireActionSignoff(ctx context.Context, flightID, helicopterID []byte) *apperrorsx.AppError {
|
||||
if h.signoffChecker == nil || h.complaintChecker == nil {
|
||||
return nil
|
||||
}
|
||||
open, err := h.complaintChecker.ActiveGroundingByHelicopterIDs(ctx, [][]byte{helicopterID})
|
||||
if err != nil {
|
||||
return apperrorsx.New(apperrorsx.ErrEASAReleaseCreateFailed).WithCause(err)
|
||||
}
|
||||
if len(open) > 0 {
|
||||
ids := make([][]byte, 0, len(open))
|
||||
for i := range open {
|
||||
ids = append(ids, open[i].ID)
|
||||
}
|
||||
signoffs, serr := h.signoffChecker.GetByComplaintIDs(ctx, ids)
|
||||
if serr != nil {
|
||||
return apperrorsx.New(apperrorsx.ErrEASAReleaseCreateFailed).WithCause(serr)
|
||||
}
|
||||
for i := range open {
|
||||
if so := signoffs[string(open[i].ID)]; so == nil || !so.IsSigned() {
|
||||
return apperrorsx.New(apperrorsx.ErrEASAReleaseSignoffRequired)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
standalone, serr := h.signoffChecker.GetByFlight(ctx, flightID)
|
||||
if serr != nil {
|
||||
return apperrorsx.New(apperrorsx.ErrEASAReleaseCreateFailed).WithCause(serr)
|
||||
}
|
||||
if standalone == nil || !standalone.IsSigned() {
|
||||
return apperrorsx.New(apperrorsx.ErrEASAReleaseSignoffRequired)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *EASAReleaseHandler) WithAOGRecalculator(svc aogRecalculator) *EASAReleaseHandler {
|
||||
h.aogRecalc = svc
|
||||
return h
|
||||
}
|
||||
|
||||
func parseEASADate(s *string) (*time.Time, error) {
|
||||
if s == nil || strings.TrimSpace(*s) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", strings.TrimSpace(*s))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func writeEASAReleaseServiceError(c *fiber.Ctx, err error, fallback apperrorsx.Def) error {
|
||||
switch {
|
||||
case errors.Is(err, easarelease.ErrNotFound):
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseNotFound).WithCause(err))
|
||||
case errors.Is(err, easarelease.ErrAlreadySigned):
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseAlreadySigned).WithCause(err))
|
||||
case errors.Is(err, easarelease.ErrIncompleteForSign):
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseIncompleteForSign).WithCause(err))
|
||||
default:
|
||||
return apperrorsx.Write(c, apperrorsx.New(fallback).WithCause(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EASAReleaseHandler) easaResource(ctx context.Context, row *easarelease.EASARelease) responsedto.EASAReleaseResource {
|
||||
res := easaReleaseResource(row)
|
||||
if row != nil {
|
||||
res.Attributes.EASASignerName, res.Attributes.EASASignerShort = h.signerNameAndShort(ctx, row.EASASignerID)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func easaReleaseResource(row *easarelease.EASARelease) responsedto.EASAReleaseResource {
|
||||
if row == nil {
|
||||
return responsedto.EASAReleaseResource{}
|
||||
}
|
||||
id, _ := uuidv7.BytesToString(row.ID)
|
||||
out := responsedto.EASAReleaseResource{
|
||||
Type: "easa_release",
|
||||
ID: id,
|
||||
Attributes: responsedto.EASAReleaseAttributes{
|
||||
HelicopterID: idString(row.HelicopterID),
|
||||
ComplaintID: idString(row.ComplaintID),
|
||||
FlightID: idString(row.FlightID),
|
||||
EASAACHours: row.EASAACHours,
|
||||
EASALocation: row.EASALocation,
|
||||
EASAWONo: row.EASAWONo,
|
||||
EASAMaintManualRevAirframe: row.EASAMaintManualRevAirframe,
|
||||
EASAMaintManualRevEngine: row.EASAMaintManualRevEngine,
|
||||
EASASignerID: row.EASASignerID,
|
||||
EASAPermitNo: row.EASAPermitNo,
|
||||
IsSigned: row.IsSigned(),
|
||||
SignedBy: idString(row.SignedBy),
|
||||
MissingSignFields: row.MissingSignFields(),
|
||||
},
|
||||
}
|
||||
if row.EASADate != nil {
|
||||
v := row.EASADate.UTC().Format("2006-01-02")
|
||||
out.Attributes.EASADate = &v
|
||||
}
|
||||
if row.SignedAt != nil {
|
||||
v := row.SignedAt.UTC().Format(time.RFC3339)
|
||||
out.Attributes.SignedAt = &v
|
||||
}
|
||||
if !row.CreatedAt.IsZero() {
|
||||
out.Attributes.CreatedAt = row.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !row.UpdatedAt.IsZero() {
|
||||
out.Attributes.UpdatedAt = row.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Create godoc
|
||||
// @Summary Create EASA maintenance release (draft) for a helicopter
|
||||
// @Tags EASA Releases
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body requestdto.EASAReleaseCreateRequest true "JSON:API EASA release create request"
|
||||
// @Success 201 {object} responsedto.EASAReleaseResponse
|
||||
// @Router /api/v1/easa-releases/create [post]
|
||||
func (h *EASAReleaseHandler) Create(c *fiber.Ctx) error {
|
||||
var req requestdto.EASAReleaseCreateRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return writeBodyParseError(c, err, apperrorsx.ErrEASAReleaseInvalidJSON)
|
||||
}
|
||||
if errs := h.validate.ValidateStruct(req); errs != nil {
|
||||
return jsonapi.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
|
||||
}
|
||||
helicopterID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.HelicopterID))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/helicopter_id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
var flightID []byte
|
||||
if s := strings.TrimSpace(req.Data.Attributes.FlightID); s != "" {
|
||||
flightID, err = uuidv7.ParseString(s)
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/flight_id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
}
|
||||
var complaintID []byte
|
||||
if s := strings.TrimSpace(req.Data.Attributes.ComplaintID); s != "" {
|
||||
complaintID, err = uuidv7.ParseString(s)
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/complaint_id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
}
|
||||
// complaint_id and flight_id are mutually exclusive: a release either closes one
|
||||
// specific complaint or all of a flight's complaints, never both.
|
||||
if len(complaintID) == 16 && len(flightID) == 16 {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidPayload)
|
||||
appErr.Detail = "complaint_id and flight_id are mutually exclusive; provide at most one"
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/complaint_id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
if aerr := h.requireActionSignoff(c.UserContext(), flightID, helicopterID); aerr != nil {
|
||||
return apperrorsx.Write(c, aerr)
|
||||
}
|
||||
easaDate, err := parseEASADate(req.Data.Attributes.EASADate)
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidPayload).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/easa_date"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
row := &easarelease.EASARelease{
|
||||
HelicopterID: helicopterID,
|
||||
ComplaintID: complaintID,
|
||||
FlightID: flightID,
|
||||
EASADate: easaDate,
|
||||
EASAACHours: emptyToNilString(req.Data.Attributes.EASAACHours),
|
||||
EASALocation: emptyToNilString(req.Data.Attributes.EASALocation),
|
||||
EASAWONo: emptyToNilString(req.Data.Attributes.EASAWONo),
|
||||
EASAMaintManualRevAirframe: emptyToNilString(req.Data.Attributes.EASAMaintManualRevAirframe),
|
||||
EASAMaintManualRevEngine: emptyToNilString(req.Data.Attributes.EASAMaintManualRevEngine),
|
||||
EASASignerID: emptyToNilString(req.Data.Attributes.EASASignerID),
|
||||
EASAPermitNo: emptyToNilString(req.Data.Attributes.EASAPermitNo),
|
||||
CreatedBy: actorUserID(c),
|
||||
UpdatedBy: actorUserID(c),
|
||||
}
|
||||
if req.Data.Attributes.Sign {
|
||||
if len(row.MissingSignFields()) > 0 {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseIncompleteForSign)
|
||||
appErr.Detail = map[string]any{"missing_sign_fields": row.MissingSignFields()}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
row.SignedAt = &now
|
||||
row.SignedBy = actorUserID(c)
|
||||
}
|
||||
if err := h.svc.Create(c.UserContext(), row); err != nil {
|
||||
return writeEASAReleaseServiceError(c, err, apperrorsx.ErrEASAReleaseCreateFailed)
|
||||
}
|
||||
created, _ := h.svc.GetByID(c.UserContext(), row.ID)
|
||||
if created == nil {
|
||||
created = row
|
||||
}
|
||||
actor := actorUserID(c)
|
||||
if created.IsSigned() {
|
||||
h.closeComplaintsForRelease(c.UserContext(), created, actor)
|
||||
h.consumeStandaloneSignoff(c.UserContext(), created.FlightID, actor)
|
||||
}
|
||||
if h.historySvc != nil {
|
||||
_ = h.historySvc.Record(c.UserContext(), created.HelicopterID, fleethistory.EntityEASARelease, created.ID, fleethistory.ActionEASACreated, "EASA release created (draft)", actor)
|
||||
if created.IsSigned() {
|
||||
_ = h.historySvc.Record(c.UserContext(), created.HelicopterID, fleethistory.EntityEASARelease, created.ID, fleethistory.ActionEASASigned, "Release to Service (EASA signed)", actor)
|
||||
}
|
||||
}
|
||||
return response.Write(c, fiber.StatusCreated, h.easaResource(c.UserContext(), created))
|
||||
}
|
||||
|
||||
// GetByID godoc
|
||||
// @Summary Get EASA release by ID
|
||||
// @Tags EASA Releases
|
||||
// @Produce json
|
||||
// @Param id path string true "EASA release ID (UUIDv7)"
|
||||
// @Success 200 {object} responsedto.EASAReleaseResponse
|
||||
// @Router /api/v1/easa-releases/get/{id} [get]
|
||||
func (h *EASAReleaseHandler) GetByID(c *fiber.Ctx) error {
|
||||
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
row, err := h.svc.GetByID(c.UserContext(), id)
|
||||
if err != nil {
|
||||
return writeEASAReleaseServiceError(c, err, apperrorsx.ErrEASAReleaseNotFound)
|
||||
}
|
||||
if row == nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseNotFound))
|
||||
}
|
||||
return response.Write(c, fiber.StatusOK, h.easaResource(c.UserContext(), row))
|
||||
}
|
||||
|
||||
// GetByFlight godoc
|
||||
// @Summary Get the current EASA release for a flight
|
||||
// @Tags EASA Releases
|
||||
// @Produce json
|
||||
// @Param flight_id path string true "Flight ID (UUIDv7)"
|
||||
// @Success 200 {object} responsedto.EASAReleaseResponse
|
||||
// @Router /api/v1/easa-releases/get-by-flight/{flight_id} [get]
|
||||
func (h *EASAReleaseHandler) GetByFlight(c *fiber.Ctx) error {
|
||||
flightID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("flight_id")))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/flight_id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
row, err := h.svc.GetByFlightID(c.UserContext(), flightID)
|
||||
if err != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseListFailed).WithCause(err))
|
||||
}
|
||||
if row == nil {
|
||||
// No release for this flight yet — return an empty resource (not 404).
|
||||
return response.Write(c, fiber.StatusOK, responsedto.EASAReleaseResource{})
|
||||
}
|
||||
return response.Write(c, fiber.StatusOK, h.easaResource(c.UserContext(), row))
|
||||
}
|
||||
|
||||
// GetByComplaint godoc
|
||||
// @Summary Get the current EASA release for a complaint
|
||||
// @Tags EASA Releases
|
||||
// @Produce json
|
||||
// @Param complaint_id path string true "Complaint ID (UUIDv7)"
|
||||
// @Success 200 {object} responsedto.EASAReleaseResponse
|
||||
// @Router /api/v1/easa-releases/get-by-complaint/{complaint_id} [get]
|
||||
func (h *EASAReleaseHandler) GetByComplaint(c *fiber.Ctx) error {
|
||||
complaintID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("complaint_id")))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/complaint_id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
row, err := h.svc.GetByComplaintID(c.UserContext(), complaintID)
|
||||
if err != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseListFailed).WithCause(err))
|
||||
}
|
||||
if row == nil {
|
||||
// No release for this complaint yet — return an empty resource (not 404).
|
||||
return response.Write(c, fiber.StatusOK, responsedto.EASAReleaseResource{})
|
||||
}
|
||||
return response.Write(c, fiber.StatusOK, h.easaResource(c.UserContext(), row))
|
||||
}
|
||||
|
||||
// ListByHelicopter godoc
|
||||
// @Summary List EASA releases for a helicopter
|
||||
// @Tags EASA Releases
|
||||
// @Produce json
|
||||
// @Param helicopter_id path string true "Helicopter ID (UUIDv7)"
|
||||
// @Success 200 {object} responsedto.EASAReleaseListResponse
|
||||
// @Router /api/v1/easa-releases/get-by-helicopter/{helicopter_id} [get]
|
||||
func (h *EASAReleaseHandler) ListByHelicopter(c *fiber.Ctx) error {
|
||||
helicopterID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("helicopter_id")))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/helicopter_id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
rows, err := h.svc.ListByHelicopter(c.UserContext(), helicopterID)
|
||||
if err != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseListFailed).WithCause(err))
|
||||
}
|
||||
data := make([]responsedto.EASAReleaseResource, 0, len(rows))
|
||||
for i := range rows {
|
||||
data = append(data, h.easaResource(c.UserContext(), &rows[i]))
|
||||
}
|
||||
return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{
|
||||
"total": int64(len(data)),
|
||||
})
|
||||
}
|
||||
|
||||
// Update godoc
|
||||
// @Summary Update EASA release (draft fields)
|
||||
// @Tags EASA Releases
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "EASA release ID (UUIDv7)"
|
||||
// @Param request body requestdto.EASAReleaseUpdateRequest true "JSON:API EASA release update request"
|
||||
// @Success 200 {object} responsedto.EASAReleaseResponse
|
||||
// @Router /api/v1/easa-releases/update/{id} [patch]
|
||||
func (h *EASAReleaseHandler) Update(c *fiber.Ctx) error {
|
||||
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
var req requestdto.EASAReleaseUpdateRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return writeBodyParseError(c, err, apperrorsx.ErrEASAReleaseInvalidJSON)
|
||||
}
|
||||
if errs := h.validate.ValidateStruct(req); errs != nil {
|
||||
return jsonapi.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
|
||||
}
|
||||
if strings.TrimSpace(req.Data.ID) != "" && strings.TrimSpace(req.Data.ID) != strings.TrimSpace(c.Params("id")) {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidPayload)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
row, err := h.svc.GetByID(c.UserContext(), id)
|
||||
if err != nil {
|
||||
return writeEASAReleaseServiceError(c, err, apperrorsx.ErrEASAReleaseNotFound)
|
||||
}
|
||||
if row == nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseNotFound))
|
||||
}
|
||||
a := req.Data.Attributes
|
||||
if a.EASADate != nil {
|
||||
d, perr := parseEASADate(a.EASADate)
|
||||
if perr != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidPayload).WithCause(perr)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/easa_date"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
row.EASADate = d
|
||||
}
|
||||
if a.EASAACHours != nil {
|
||||
row.EASAACHours = emptyToNilString(a.EASAACHours)
|
||||
}
|
||||
if a.EASALocation != nil {
|
||||
row.EASALocation = emptyToNilString(a.EASALocation)
|
||||
}
|
||||
if a.EASAWONo != nil {
|
||||
row.EASAWONo = emptyToNilString(a.EASAWONo)
|
||||
}
|
||||
if a.EASAMaintManualRevAirframe != nil {
|
||||
row.EASAMaintManualRevAirframe = emptyToNilString(a.EASAMaintManualRevAirframe)
|
||||
}
|
||||
if a.EASAMaintManualRevEngine != nil {
|
||||
row.EASAMaintManualRevEngine = emptyToNilString(a.EASAMaintManualRevEngine)
|
||||
}
|
||||
if a.EASASignerID != nil {
|
||||
row.EASASignerID = emptyToNilString(a.EASASignerID)
|
||||
}
|
||||
if a.EASAPermitNo != nil {
|
||||
row.EASAPermitNo = emptyToNilString(a.EASAPermitNo)
|
||||
}
|
||||
row.UpdatedBy = actorUserID(c)
|
||||
if err := h.svc.Update(c.UserContext(), row); err != nil {
|
||||
return writeEASAReleaseServiceError(c, err, apperrorsx.ErrEASAReleaseUpdateFailed)
|
||||
}
|
||||
updated, _ := h.svc.GetByID(c.UserContext(), id)
|
||||
if updated == nil {
|
||||
updated = row
|
||||
}
|
||||
return response.Write(c, fiber.StatusOK, h.easaResource(c.UserContext(), updated))
|
||||
}
|
||||
|
||||
// Sign godoc
|
||||
// @Summary Sign the EASA maintenance release (closes the helicopter's open complaints)
|
||||
// @Description Signs the release and closes the helicopter's open complaints. The corrective-action sign-off is re-checked at sign time: if the sign-off was invalidated since the draft was created (e.g. its complaint was reopened) it returns 422 `EASA_RELEASE_SIGNOFF_REQUIRED` and does not sign.
|
||||
// @Tags EASA Releases
|
||||
// @Produce json
|
||||
// @Param id path string true "EASA release ID (UUIDv7)"
|
||||
// @Success 200 {object} responsedto.EASAReleaseResponse
|
||||
// @Failure 404 {object} jsonapi.ErrorDocument
|
||||
// @Failure 422 {object} jsonapi.ErrorDocument
|
||||
// @Router /api/v1/easa-releases/sign/{id} [post]
|
||||
func (h *EASAReleaseHandler) Sign(c *fiber.Ctx) error {
|
||||
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
// Re-check the sign-off gate at sign time: a draft created earlier may have had its
|
||||
// corrective-action sign-off invalidated since (e.g. its complaint was reopened), so
|
||||
// signing must not bypass the requirement.
|
||||
existing, gerr := h.svc.GetByID(c.UserContext(), id)
|
||||
if gerr != nil {
|
||||
return writeEASAReleaseServiceError(c, gerr, apperrorsx.ErrEASAReleaseSignFailed)
|
||||
}
|
||||
if existing == nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseNotFound))
|
||||
}
|
||||
if aerr := h.requireActionSignoff(c.UserContext(), existing.FlightID, existing.HelicopterID); aerr != nil {
|
||||
return apperrorsx.Write(c, aerr)
|
||||
}
|
||||
if err := h.svc.Sign(c.UserContext(), id, actorUserID(c)); err != nil {
|
||||
return writeEASAReleaseServiceError(c, err, apperrorsx.ErrEASAReleaseSignFailed)
|
||||
}
|
||||
row, _ := h.svc.GetByID(c.UserContext(), id)
|
||||
if row == nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrEASAReleaseNotFound))
|
||||
}
|
||||
actor := actorUserID(c)
|
||||
h.closeComplaintsForRelease(c.UserContext(), row, actor)
|
||||
h.consumeStandaloneSignoff(c.UserContext(), row.FlightID, actor)
|
||||
if h.historySvc != nil {
|
||||
_ = h.historySvc.Record(c.UserContext(), row.HelicopterID, fleethistory.EntityEASARelease, row.ID, fleethistory.ActionEASASigned, "Release to Service (EASA signed)", actor)
|
||||
}
|
||||
return response.Write(c, fiber.StatusOK, h.easaResource(c.UserContext(), row))
|
||||
}
|
||||
|
||||
// Delete godoc
|
||||
// @Summary Delete (void) an EASA release
|
||||
// @Tags EASA Releases
|
||||
// @Produce json
|
||||
// @Param id path string true "EASA release ID (UUIDv7)"
|
||||
// @Success 200 {object} responsedto.EASAReleaseDeleteResponse
|
||||
// @Router /api/v1/easa-releases/delete/{id} [delete]
|
||||
func (h *EASAReleaseHandler) Delete(c *fiber.Ctx) error {
|
||||
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
|
||||
if err != nil {
|
||||
appErr := apperrorsx.New(apperrorsx.ErrEASAReleaseInvalidUUID).WithCause(err)
|
||||
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
|
||||
return apperrorsx.Write(c, appErr)
|
||||
}
|
||||
if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
|
||||
return writeEASAReleaseServiceError(c, err, apperrorsx.ErrEASAReleaseDeleteFailed)
|
||||
}
|
||||
return response.Write(c, fiber.StatusOK, map[string]any{"data": map[string]any{"type": "easa_release_delete", "attributes": map[string]bool{"deleted": true}}})
|
||||
}
|
||||
Reference in New Issue
Block a user