package handlers import ( "errors" "strings" "time" "github.com/gofiber/fiber/v2" fleethistory "wucher/internal/domain/fleet_history" "wucher/internal/domain/mcf" "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 MCFHandler struct { svc mcf.Service historySvc fleetHistoryRecorder validate *validators.Validator } func NewMCFHandler(svc mcf.Service) *MCFHandler { return &MCFHandler{svc: svc, validate: validators.New()} } func (h *MCFHandler) WithHistoryService(svc fleetHistoryRecorder) *MCFHandler { h.historySvc = svc return h } func writeMCFServiceError(c *fiber.Ctx, err error, fallback apperrorsx.Def) error { if errors.Is(err, mcf.ErrNotFound) { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrMCFNotFound).WithCause(err)) } return apperrorsx.Write(c, apperrorsx.New(fallback).WithCause(err)) } func mcfResource(row *mcf.MaintenanceCheckFlight) responsedto.MCFResource { if row == nil { return responsedto.MCFResource{} } id, _ := uuidv7.BytesToString(row.ID) out := responsedto.MCFResource{ Type: "mcf", ID: id, Attributes: responsedto.MCFAttributes{ HelicopterID: idString(row.HelicopterID), FlightID: idString(row.FlightID), AFHours: row.AFHours, LandingCount: row.LandingCount, DecidedBy: idString(row.DecidedBy), Result: row.Result, Notes: row.Notes, CompletedBy: idString(row.CompletedBy), IsComplete: row.IsComplete(), IsCleared: row.IsCleared(), IsPending: row.IsPending(), }, } if row.Date != nil && !row.Date.IsZero() { out.Attributes.Date = row.Date.UTC().Format("2006-01-02") } if row.UTCtime != nil && !row.UTCtime.IsZero() { v := row.UTCtime.UTC().Format(time.RFC3339) out.Attributes.UTCTime = &v } if row.DecidedAt != nil { v := row.DecidedAt.UTC().Format(time.RFC3339) out.Attributes.DecidedAt = &v } if row.CompletedAt != nil { v := row.CompletedAt.UTC().Format(time.RFC3339) out.Attributes.CompletedAt = &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 } // Set godoc // @Summary Set a helicopter's MCF Yes/No — draft work endpoint (create/update or cancel) // @Description Toggles and edits a helicopter's MCF. `active:true` (or omitted) ACTIVATES/EDITS it — creates the draft if none is open (201), or UPDATES the open draft with the provided check-flight fields (200; af_hours, landing_count, utc_time, date, result, notes — all optional, partial edits allowed). Finalize it via POST /mcf/sign/{id} (sign:true). `active:false` DEACTIVATES it — cancels the open (unsigned) draft ("tidak jadi") and releases the MCF status; the cancelled record stays in the list. A signed MCF is a real event and is not cancelled. // @Tags MCF // @Accept json // @Produce json // @Param helicopter_id path string true "Helicopter ID (UUIDv7)" // @Param request body requestdto.MCFSetRequest true "JSON:API MCF set request" // @Success 201 {object} responsedto.MCFResponse // @Success 200 {object} responsedto.MCFResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/mcf/set/{helicopter_id} [post] func (h *MCFHandler) Set(c *fiber.Ctx) error { helicopterID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("helicopter_id"))) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidUUID).WithCause(err) appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/helicopter_id"} return apperrorsx.Write(c, appErr) } var req requestdto.MCFSetRequest if err := c.BodyParser(&req); err != nil { return writeBodyParseError(c, err, apperrorsx.ErrMCFInvalidJSON) } if errs := h.validate.ValidateStruct(req); errs != nil { return jsonapi.WriteErrors(c, fiber.StatusUnprocessableEntity, errs) } attr := req.Data.Attributes active := attr.Active == nil || *attr.Active now := time.Now().UTC() actor := actorUserID(c) // Deactivate (No): cancel the helicopter's open (unsigned) draft → release MCF status. // A signed MCF is a real event and is left untouched. if !active { row, gerr := h.svc.GetLatestByHelicopter(c.UserContext(), helicopterID) if gerr != nil { return writeMCFServiceError(c, gerr, apperrorsx.ErrMCFCreateFailed) } if row == nil { // No MCF at all — already "No" (idempotent no-op). return response.Write(c, fiber.StatusOK, map[string]any{"data": map[string]any{"type": "mcf_set", "attributes": map[string]bool{"active": false}}}) } if !row.IsOpen() { // Signed (or already cancelled) MCF is a real record — NOT cancelled by set:false. // Return it as-is so the client/fleet still shows the completed data. return response.Write(c, fiber.StatusOK, responsedto.MCFResponse{Data: mcfResource(row)}) } row.CancelledAt = &now row.CancelledBy = actor row.UpdatedBy = actor if err := h.svc.Update(c.UserContext(), row); err != nil { return writeMCFServiceError(c, err, apperrorsx.ErrMCFCreateFailed) } updated, _ := h.svc.GetByID(c.UserContext(), row.ID) if updated == nil { updated = row } return response.Write(c, fiber.StatusOK, responsedto.MCFResponse{Data: mcfResource(updated)}) } // Activate (Yes): the draft work endpoint — create the draft if none is open, or // UPDATE the open draft with the provided check-flight fields. Finalize via /mcf/sign. var flightID []byte if s := strings.TrimSpace(attr.FlightID); s != "" { flightID, err = uuidv7.ParseString(s) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidUUID).WithCause(err) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/flight_id"} return apperrorsx.Write(c, appErr) } } draftFields := mcfSignFields{ Result: attr.Result, AFHours: attr.AFHours, LandingCount: attr.LandingCount, UTCTime: attr.UTCTime, Date: attr.Date, Notes: attr.Notes, } // Update the open draft in place if one exists. if existing, gerr := h.svc.GetLatestByHelicopter(c.UserContext(), helicopterID); gerr != nil { return writeMCFServiceError(c, gerr, apperrorsx.ErrMCFCreateFailed) } else if existing != nil && existing.IsOpen() { if aerr := applyMCFDraftFields(existing, draftFields); aerr != nil { return apperrorsx.Write(c, aerr) } if len(flightID) == 16 { existing.FlightID = flightID } existing.UpdatedBy = actor if err := h.svc.Update(c.UserContext(), existing); err != nil { return writeMCFServiceError(c, err, apperrorsx.ErrMCFCreateFailed) } updated, _ := h.svc.GetByID(c.UserContext(), existing.ID) if updated == nil { updated = existing } return response.Write(c, fiber.StatusOK, responsedto.MCFResponse{Data: mcfResource(updated)}) } // No open draft → create a new one (with any provided data). row := &mcf.MaintenanceCheckFlight{ HelicopterID: helicopterID, FlightID: flightID, DecidedBy: actor, DecidedAt: &now, CreatedBy: actor, UpdatedBy: actor, } if aerr := applyMCFDraftFields(row, draftFields); aerr != nil { return apperrorsx.Write(c, aerr) } if err := h.svc.Create(c.UserContext(), row); err != nil { return writeMCFServiceError(c, err, apperrorsx.ErrMCFCreateFailed) } created, _ := h.svc.GetByID(c.UserContext(), row.ID) if created == nil { created = row } return response.Write(c, fiber.StatusCreated, responsedto.MCFResponse{Data: mcfResource(created)}) } // GetByID godoc // @Summary Get MCF by ID // @Tags MCF // @Produce json // @Param id path string true "MCF ID (UUIDv7)" // @Success 200 {object} responsedto.MCFResponse // @Router /api/v1/mcf/get/{id} [get] func (h *MCFHandler) GetByID(c *fiber.Ctx) error { id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id"))) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidUUID).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 writeMCFServiceError(c, err, apperrorsx.ErrMCFNotFound) } if row == nil { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrMCFNotFound)) } return response.Write(c, fiber.StatusOK, responsedto.MCFResponse{Data: mcfResource(row)}) } // ListByHelicopter godoc // @Summary List MCFs for a helicopter // @Tags MCF // @Produce json // @Param helicopter_id path string true "Helicopter ID (UUIDv7)" // @Success 200 {object} responsedto.MCFListResponse // @Router /api/v1/mcf/get-by-helicopter/{helicopter_id} [get] func (h *MCFHandler) ListByHelicopter(c *fiber.Ctx) error { helicopterID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("helicopter_id"))) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidUUID).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.ErrMCFListFailed).WithCause(err)) } data := make([]responsedto.MCFResource, 0, len(rows)) for i := range rows { data = append(data, mcfResource(&rows[i])) } resp := responsedto.MCFListResponse{Data: data} resp.Meta.Total = int64(len(data)) return response.Write(c, fiber.StatusOK, resp) } // mcfSignFields is the check-flight data recorded when an MCF is signed (completed). type mcfSignFields struct { Result string AFHours string LandingCount *int UTCTime string Date string Notes *string } // stampMCFSign validates the check-flight fields and stamps the row as signed/completed. // Returns a non-nil AppError (422, pointing at the offending field) on invalid input. func stampMCFSign(row *mcf.MaintenanceCheckFlight, f mcfSignFields, actor []byte) *apperrorsx.AppError { missing := "" switch { case strings.TrimSpace(f.Result) == "": missing = "result" case strings.TrimSpace(f.AFHours) == "": missing = "af_hours" case f.LandingCount == nil: missing = "landing_count" case strings.TrimSpace(f.UTCTime) == "": missing = "utc_time" case strings.TrimSpace(f.Date) == "": missing = "date" } if missing != "" { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidPayload) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/" + missing} return appErr } result := strings.TrimSpace(f.Result) if result != mcf.ResultPassed && result != mcf.ResultFailed { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidPayload) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/result"} return appErr } mcfDate, err := time.Parse("2006-01-02", strings.TrimSpace(f.Date)) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidPayload).WithCause(err) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/date"} return appErr } utcTime, err := time.Parse(time.RFC3339, strings.TrimSpace(f.UTCTime)) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidPayload).WithCause(err) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/utc_time"} return appErr } now := time.Now().UTC() d := mcfDate.UTC() utc := utcTime.UTC() row.Date = &d row.AFHours = strings.TrimSpace(f.AFHours) row.LandingCount = *f.LandingCount row.UTCtime = &utc row.Result = &result if f.Notes != nil { row.Notes = emptyToNilString(f.Notes) } row.CompletedAt = &now row.CompletedBy = append([]byte(nil), actor...) return nil } // applyMCFDraftFields saves the PROVIDED check-flight fields onto the row without // completing it (a draft save — partial input allowed). Only non-empty fields are // written; a bad date/utc_time/result → 422 pointing at the field. func applyMCFDraftFields(row *mcf.MaintenanceCheckFlight, f mcfSignFields) *apperrorsx.AppError { if s := strings.TrimSpace(f.Date); s != "" { d, err := time.Parse("2006-01-02", s) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidPayload).WithCause(err) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/date"} return appErr } du := d.UTC() row.Date = &du } if s := strings.TrimSpace(f.UTCTime); s != "" { u, err := time.Parse(time.RFC3339, s) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidPayload).WithCause(err) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/utc_time"} return appErr } uu := u.UTC() row.UTCtime = &uu } if s := strings.TrimSpace(f.AFHours); s != "" { row.AFHours = s } if f.LandingCount != nil { row.LandingCount = *f.LandingCount } if s := strings.TrimSpace(f.Result); s != "" { if s != mcf.ResultPassed && s != mcf.ResultFailed { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidPayload) appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/result"} return appErr } row.Result = &s } if f.Notes != nil { row.Notes = emptyToNilString(f.Notes) } return nil } // Sign godoc // @Summary Sign (finalize) or unsign an MCF — toggle by MCF id // @Description Finalizes an MCF's check-flight. `sign` (optional, default true): true FINALIZES it — af_hours, landing_count, utc_time, date, result required; "passed" clears the aircraft from MCF, "failed" keeps it grounded. false UNSIGNS it — reverts a signed MCF back to a draft (keeps the recorded data). Editing draft fields is done via /mcf/set; cancelling via /mcf/set (active:false). // @Tags MCF // @Accept json // @Produce json // @Param id path string true "MCF ID (UUIDv7)" // @Param request body requestdto.MCFCompleteRequest true "JSON:API MCF sign request" // @Success 200 {object} responsedto.MCFResponse // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Router /api/v1/mcf/sign/{id} [post] func (h *MCFHandler) Sign(c *fiber.Ctx) error { id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id"))) if err != nil { appErr := apperrorsx.New(apperrorsx.ErrMCFInvalidUUID).WithCause(err) appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"} return apperrorsx.Write(c, appErr) } var req requestdto.MCFCompleteRequest if err := c.BodyParser(&req); err != nil { return writeBodyParseError(c, err, apperrorsx.ErrMCFInvalidJSON) } attr := req.Data.Attributes signWanted := attr.Sign == nil || *attr.Sign row, err := h.svc.GetByID(c.UserContext(), id) if err != nil { return writeMCFServiceError(c, err, apperrorsx.ErrMCFCompleteFailed) } if row == nil { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrMCFNotFound)) } // Unsign (sign:false): revert a signed MCF back to a draft — clears the signature but // KEEPS the recorded data. Editing draft data is done via /mcf/set; cancelling via // /mcf/set (active:false). if !signWanted { row.CompletedAt = nil row.CompletedBy = nil row.UpdatedBy = actorUserID(c) if err := h.svc.Update(c.UserContext(), row); err != nil { return writeMCFServiceError(c, err, apperrorsx.ErrMCFCompleteFailed) } updated, _ := h.svc.GetByID(c.UserContext(), id) if updated == nil { updated = row } return response.Write(c, fiber.StatusOK, responsedto.MCFResponse{Data: mcfResource(updated)}) } // Sign: the check-flight fields are required. if errs := h.validate.ValidateStruct(req); errs != nil { return jsonapi.WriteErrors(c, fiber.StatusUnprocessableEntity, errs) } if aerr := stampMCFSign(row, mcfSignFields{ Result: attr.Result, AFHours: attr.AFHours, LandingCount: attr.LandingCount, UTCTime: attr.UTCTime, Date: attr.Date, Notes: attr.Notes, }, actorUserID(c)); aerr != nil { return apperrorsx.Write(c, aerr) } row.UpdatedBy = actorUserID(c) if err := h.svc.Update(c.UserContext(), row); err != nil { return writeMCFServiceError(c, err, apperrorsx.ErrMCFCompleteFailed) } updated, _ := h.svc.GetByID(c.UserContext(), row.ID) if updated == nil { updated = row } if h.historySvc != nil { _ = h.historySvc.Record(c.UserContext(), updated.HelicopterID, fleethistory.EntityMCF, updated.ID, fleethistory.ActionMCFResult, "MCF result: "+strings.TrimSpace(attr.Result), actorUserID(c)) } return response.Write(c, fiber.StatusOK, responsedto.MCFResponse{Data: mcfResource(updated)}) }