init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

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