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,450 @@
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)})
}