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