451 lines
16 KiB
Go
451 lines
16 KiB
Go
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)})
|
|
}
|
|
|