init push
This commit is contained in:
5
internal/domain/action_signoff/errors.go
Normal file
5
internal/domain/action_signoff/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package actionsignoff
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("action sign-off not found")
|
||||
49
internal/domain/action_signoff/model.go
Normal file
49
internal/domain/action_signoff/model.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package actionsignoff
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightdomain "wucher/internal/domain/flight"
|
||||
helicopter "wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// ActionSignoff is a corrective-action sign-off for a helicopter. When linked to a
|
||||
// complaint (ComplaintID set) it records that the complaint's corrective action was
|
||||
// signed off; when unlinked (ComplaintID nil) it is a complaint-independent duty
|
||||
// sign-off (gating the EASA maintenance release even without a complaint). There is
|
||||
// one row per complaint, and one current unlinked row per helicopter.
|
||||
type ActionSignoff struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
// FlightID scopes the standalone (complaint-independent) sign-off to a flight,
|
||||
// so it no longer leaks across takeovers of the same aircraft. Nullable; unset
|
||||
// for complaint-linked rows (those are keyed by complaint_id).
|
||||
FlightID []byte `gorm:"type:binary(16);index;column:flight_id"`
|
||||
// ComplaintID unique (NULLs distinct in MySQL) → one sign-off per complaint;
|
||||
// standalone rows (NULL) are unconstrained here and instead deduped per flight
|
||||
// by the functional unique index uidx_action_signoffs_standalone_flight.
|
||||
ComplaintID []byte `gorm:"type:binary(16);uniqueIndex:uidx_action_signoffs_complaint_id;column:complaint_id"`
|
||||
|
||||
SignedAt *time.Time `gorm:"type:datetime(3);column:signed_at"`
|
||||
SignedBy []byte `gorm:"type:binary(16);column:signed_by"`
|
||||
|
||||
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
|
||||
Flight *flightdomain.Flight `gorm:"foreignKey:FlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"type:datetime(3);column:updated_at"`
|
||||
}
|
||||
|
||||
func (ActionSignoff) TableName() string { return "action_signoffs" }
|
||||
|
||||
func (a *ActionSignoff) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ActionSignoff) IsSigned() bool { return a != nil && a.SignedAt != nil }
|
||||
11
internal/domain/action_signoff/repository.go
Normal file
11
internal/domain/action_signoff/repository.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package actionsignoff
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
GetByFlight(ctx context.Context, flightID []byte) (*ActionSignoff, error)
|
||||
GetByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
|
||||
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*ActionSignoff, error)
|
||||
Create(ctx context.Context, row *ActionSignoff) error
|
||||
Update(ctx context.Context, row *ActionSignoff) error
|
||||
}
|
||||
16
internal/domain/action_signoff/service.go
Normal file
16
internal/domain/action_signoff/service.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package actionsignoff
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
GetByFlight(ctx context.Context, flightID []byte) (*ActionSignoff, error)
|
||||
GetByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
|
||||
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*ActionSignoff, error)
|
||||
Sign(ctx context.Context, flightID, helicopterID []byte, signer []byte) (*ActionSignoff, error)
|
||||
SignForComplaint(ctx context.Context, complaintID, flightID, helicopterID []byte, signer []byte) (*ActionSignoff, error)
|
||||
Unsign(ctx context.Context, flightID []byte, actor []byte) (*ActionSignoff, error)
|
||||
// UnsignByComplaint clears the signature on a complaint-linked sign-off (no-op when
|
||||
// there is none or it is already unsigned) — used when a closed complaint is reopened
|
||||
// and to toggle a complaint sign-off off. Returns nil row when none exists.
|
||||
UnsignByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
|
||||
}
|
||||
58
internal/domain/after_flight_inspection/model.go
Normal file
58
internal/domain/after_flight_inspection/model.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package afterflightinspection
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type AfterFlightInspection struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightInspectionID []byte `gorm:"type:binary(16);not null;uniqueIndex;column:flight_inspection_id"`
|
||||
OilEngineNR1Checked *string `gorm:"type:varchar(255);null;column:oil_engine_nr1_checked"`
|
||||
OilEngineNR2Checked *string `gorm:"type:varchar(255);null;column:oil_engine_nr2_checked"`
|
||||
HydraulicLHChecked *string `gorm:"type:varchar(255);null;column:hydraulic_lh_checked"`
|
||||
HydraulicRHChecked *string `gorm:"type:varchar(255);null;column:hydraulic_rh_checked"`
|
||||
OilTransmissionMGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_mgb_checked"`
|
||||
OilTransmissionIGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_igb_checked"`
|
||||
OilTransmissionTGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_tgb_checked"`
|
||||
Note *string `gorm:"type:text;column:note"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (AfterFlightInspection) TableName() string { return "after_flight_inspections" }
|
||||
|
||||
func (a *AfterFlightInspection) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidFlightInspectionID = errors.New("flight_inspection_id is invalid")
|
||||
ErrFlightInspectionNotFound = errors.New("flight inspection not found")
|
||||
ErrCapabilitiesUnavailable = errors.New("helicopter capabilities could not be resolved")
|
||||
|
||||
ErrNR1Required = errors.New("oil_engine_nr1_checked is required for this helicopter")
|
||||
ErrNR1NotAvailable = errors.New("NR1 capability not available on this helicopter")
|
||||
ErrNR2Required = errors.New("oil_engine_nr2_checked is required for this helicopter")
|
||||
ErrNR2NotAvailable = errors.New("NR2 capability not available on this helicopter")
|
||||
ErrLHRequired = errors.New("hydraulic_lh_checked is required for this helicopter")
|
||||
ErrLHNotAvailable = errors.New("LH capability not available on this helicopter")
|
||||
ErrRHRequired = errors.New("hydraulic_rh_checked is required for this helicopter")
|
||||
ErrRHNotAvailable = errors.New("RH capability not available on this helicopter")
|
||||
ErrMGBRequired = errors.New("oil_transmission_mgb_checked is required for this helicopter")
|
||||
ErrMGBNotAvailable = errors.New("MGB capability not available on this helicopter")
|
||||
ErrIGBRequired = errors.New("oil_transmission_igb_checked is required for this helicopter")
|
||||
ErrIGBNotAvailable = errors.New("IGB capability not available on this helicopter")
|
||||
ErrTGBRequired = errors.New("oil_transmission_tgb_checked is required for this helicopter")
|
||||
ErrTGBNotAvailable = errors.New("TGB capability not available on this helicopter")
|
||||
)
|
||||
21
internal/domain/after_flight_inspection/repository.go
Normal file
21
internal/domain/after_flight_inspection/repository.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package afterflightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Upsert(ctx context.Context, row *AfterFlightInspection) error
|
||||
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*AfterFlightInspection, error)
|
||||
DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error
|
||||
FlightInspectionExists(ctx context.Context, flightInspectionID []byte) (bool, error)
|
||||
GetHelicopterCapabilities(ctx context.Context, flightInspectionID []byte) (*HelicopterCapabilities, error)
|
||||
}
|
||||
|
||||
type HelicopterCapabilities struct {
|
||||
NR1 bool
|
||||
NR2 bool
|
||||
LH bool
|
||||
RH bool
|
||||
MGB bool
|
||||
IGB bool
|
||||
TGB bool
|
||||
}
|
||||
20
internal/domain/after_flight_inspection/service.go
Normal file
20
internal/domain/after_flight_inspection/service.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package afterflightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Upsert(ctx context.Context, flightInspectionID []byte, req *UpsertRequest) (*AfterFlightInspection, error)
|
||||
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*AfterFlightInspection, error)
|
||||
DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
OilEngineNR1Checked *string
|
||||
OilEngineNR2Checked *string
|
||||
HydraulicLHChecked *string
|
||||
HydraulicRHChecked *string
|
||||
OilTransmissionMGBChecked *string
|
||||
OilTransmissionIGBChecked *string
|
||||
OilTransmissionTGBChecked *string
|
||||
Note *string
|
||||
}
|
||||
230
internal/domain/air_rescue_checklist/const.go
Normal file
230
internal/domain/air_rescue_checklist/const.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
AirRescuerChecklistScopeCAT = "CAT"
|
||||
AirRescuerChecklistScopeTA = "TA"
|
||||
AirRescuerChecklistScopeMO = "MO"
|
||||
AirRescuerChecklistScopeDI = "DI"
|
||||
AirRescuerChecklistScopeMI = "MI"
|
||||
AirRescuerChecklistScopeDO = "DO"
|
||||
AirRescuerChecklistScopeFR = "FR"
|
||||
AirRescuerChecklistScopeSA = "SA"
|
||||
AirRescuerChecklistScopeSO = "SO"
|
||||
AirRescuerChecklistScopeM10 = "M10"
|
||||
AirRescuerChecklistScopeM15 = "M15"
|
||||
AirRescuerChecklistScopeM20 = "M20"
|
||||
AirRescuerChecklistScopeM25 = "M25"
|
||||
AirRescuerChecklistScopeDS = "DS"
|
||||
)
|
||||
|
||||
const (
|
||||
AirRescuerChecklistScopeOverview = AirRescuerChecklistScopeCAT
|
||||
AirRescuerChecklistScopeDaily = AirRescuerChecklistScopeTA
|
||||
AirRescuerChecklistScopeMonday = AirRescuerChecklistScopeMO
|
||||
AirRescuerChecklistScopeTuesday = AirRescuerChecklistScopeDI
|
||||
AirRescuerChecklistScopeWednesday = AirRescuerChecklistScopeMI
|
||||
AirRescuerChecklistScopeThursday = AirRescuerChecklistScopeDO
|
||||
AirRescuerChecklistScopeFriday = AirRescuerChecklistScopeFR
|
||||
AirRescuerChecklistScopeSaturday = AirRescuerChecklistScopeSA
|
||||
AirRescuerChecklistScopeSunday = AirRescuerChecklistScopeSO
|
||||
AirRescuerChecklistScopeDeadline = AirRescuerChecklistScopeDS
|
||||
)
|
||||
|
||||
var airRescuerChecklistPersistedScopeOrder = []string{
|
||||
AirRescuerChecklistScopeTA,
|
||||
AirRescuerChecklistScopeMO,
|
||||
AirRescuerChecklistScopeDI,
|
||||
AirRescuerChecklistScopeMI,
|
||||
AirRescuerChecklistScopeDO,
|
||||
AirRescuerChecklistScopeFR,
|
||||
AirRescuerChecklistScopeSA,
|
||||
AirRescuerChecklistScopeSO,
|
||||
AirRescuerChecklistScopeM10,
|
||||
AirRescuerChecklistScopeM15,
|
||||
AirRescuerChecklistScopeM20,
|
||||
AirRescuerChecklistScopeM25,
|
||||
AirRescuerChecklistScopeDS,
|
||||
}
|
||||
|
||||
var airRescuerChecklistPersistedScopeSet = map[string]struct{}{
|
||||
AirRescuerChecklistScopeTA: {},
|
||||
AirRescuerChecklistScopeMO: {},
|
||||
AirRescuerChecklistScopeDI: {},
|
||||
AirRescuerChecklistScopeMI: {},
|
||||
AirRescuerChecklistScopeDO: {},
|
||||
AirRescuerChecklistScopeFR: {},
|
||||
AirRescuerChecklistScopeSA: {},
|
||||
AirRescuerChecklistScopeSO: {},
|
||||
AirRescuerChecklistScopeM10: {},
|
||||
AirRescuerChecklistScopeM15: {},
|
||||
AirRescuerChecklistScopeM20: {},
|
||||
AirRescuerChecklistScopeM25: {},
|
||||
AirRescuerChecklistScopeDS: {},
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeDisplayNamesEN = map[string]string{
|
||||
AirRescuerChecklistScopeCAT: "Category",
|
||||
AirRescuerChecklistScopeTA: "Daily",
|
||||
AirRescuerChecklistScopeMO: "Monday",
|
||||
AirRescuerChecklistScopeDI: "Tuesday",
|
||||
AirRescuerChecklistScopeMI: "Wednesday",
|
||||
AirRescuerChecklistScopeDO: "Thursday",
|
||||
AirRescuerChecklistScopeFR: "Friday",
|
||||
AirRescuerChecklistScopeSA: "Saturday",
|
||||
AirRescuerChecklistScopeSO: "Sunday",
|
||||
AirRescuerChecklistScopeM10: "M10",
|
||||
AirRescuerChecklistScopeM15: "M15",
|
||||
AirRescuerChecklistScopeM20: "M20",
|
||||
AirRescuerChecklistScopeM25: "M25",
|
||||
AirRescuerChecklistScopeDS: "Deadline",
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeDisplayNamesDE = map[string]string{
|
||||
AirRescuerChecklistScopeCAT: "Category",
|
||||
AirRescuerChecklistScopeTA: "Taeglich",
|
||||
AirRescuerChecklistScopeMO: "Montag",
|
||||
AirRescuerChecklistScopeDI: "Dienstag",
|
||||
AirRescuerChecklistScopeMI: "Mittwoch",
|
||||
AirRescuerChecklistScopeDO: "Donnerstag",
|
||||
AirRescuerChecklistScopeFR: "Freitag",
|
||||
AirRescuerChecklistScopeSA: "Samstag",
|
||||
AirRescuerChecklistScopeSO: "Sonntag",
|
||||
AirRescuerChecklistScopeM10: "M10",
|
||||
AirRescuerChecklistScopeM15: "M15",
|
||||
AirRescuerChecklistScopeM20: "M20",
|
||||
AirRescuerChecklistScopeM25: "M25",
|
||||
AirRescuerChecklistScopeDS: "Deadline",
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeCodesEN = map[string]string{
|
||||
AirRescuerChecklistScopeCAT: "CAT",
|
||||
AirRescuerChecklistScopeTA: "DA",
|
||||
AirRescuerChecklistScopeMO: "MO",
|
||||
AirRescuerChecklistScopeDI: "TU",
|
||||
AirRescuerChecklistScopeMI: "WE",
|
||||
AirRescuerChecklistScopeDO: "TH",
|
||||
AirRescuerChecklistScopeFR: "FR",
|
||||
AirRescuerChecklistScopeSA: "SA",
|
||||
AirRescuerChecklistScopeSO: "SU",
|
||||
AirRescuerChecklistScopeM10: "M10",
|
||||
AirRescuerChecklistScopeM15: "M15",
|
||||
AirRescuerChecklistScopeM20: "M20",
|
||||
AirRescuerChecklistScopeM25: "M25",
|
||||
AirRescuerChecklistScopeDS: "DL",
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeAliases = map[string]string{
|
||||
"CAT": AirRescuerChecklistScopeCAT,
|
||||
"OVERVIEW": AirRescuerChecklistScopeCAT,
|
||||
|
||||
"TA": AirRescuerChecklistScopeTA,
|
||||
"DA": AirRescuerChecklistScopeTA,
|
||||
"DAY": AirRescuerChecklistScopeTA,
|
||||
"DAILY": AirRescuerChecklistScopeTA,
|
||||
|
||||
"MO": AirRescuerChecklistScopeMO,
|
||||
"MON": AirRescuerChecklistScopeMO,
|
||||
"MONDAY": AirRescuerChecklistScopeMO,
|
||||
|
||||
"DI": AirRescuerChecklistScopeDI,
|
||||
"TU": AirRescuerChecklistScopeDI,
|
||||
"TUE": AirRescuerChecklistScopeDI,
|
||||
"TUESDAY": AirRescuerChecklistScopeDI,
|
||||
|
||||
"MI": AirRescuerChecklistScopeMI,
|
||||
"WE": AirRescuerChecklistScopeMI,
|
||||
"WED": AirRescuerChecklistScopeMI,
|
||||
"WEDNESDAY": AirRescuerChecklistScopeMI,
|
||||
|
||||
"DO": AirRescuerChecklistScopeDO,
|
||||
"TH": AirRescuerChecklistScopeDO,
|
||||
"THU": AirRescuerChecklistScopeDO,
|
||||
"THURSDAY": AirRescuerChecklistScopeDO,
|
||||
|
||||
"FR": AirRescuerChecklistScopeFR,
|
||||
"FRI": AirRescuerChecklistScopeFR,
|
||||
"FRIDAY": AirRescuerChecklistScopeFR,
|
||||
|
||||
"SA": AirRescuerChecklistScopeSA,
|
||||
"SAT": AirRescuerChecklistScopeSA,
|
||||
"SATURDAY": AirRescuerChecklistScopeSA,
|
||||
|
||||
"SO": AirRescuerChecklistScopeSO,
|
||||
"SU": AirRescuerChecklistScopeSO,
|
||||
"SUN": AirRescuerChecklistScopeSO,
|
||||
"SUNDAY": AirRescuerChecklistScopeSO,
|
||||
|
||||
"M10": AirRescuerChecklistScopeM10,
|
||||
"M15": AirRescuerChecklistScopeM15,
|
||||
"M20": AirRescuerChecklistScopeM20,
|
||||
"M25": AirRescuerChecklistScopeM25,
|
||||
|
||||
"DS": AirRescuerChecklistScopeDS,
|
||||
"DL": AirRescuerChecklistScopeDS,
|
||||
"DEADLINE": AirRescuerChecklistScopeDS,
|
||||
}
|
||||
|
||||
func CanonicalizeAirRescuerChecklistScope(scopeCode string) string {
|
||||
return normalizeAirRescuerChecklistScope(scopeCode)
|
||||
}
|
||||
|
||||
func IsAirRescuerChecklistPersistedScope(scopeCode string) bool {
|
||||
_, ok := airRescuerChecklistPersistedScopeSet[normalizeAirRescuerChecklistScope(scopeCode)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsAirRescuerChecklistQueryScope(scopeCode string) bool {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if normalized == AirRescuerChecklistScopeCAT {
|
||||
return true
|
||||
}
|
||||
return IsAirRescuerChecklistPersistedScope(normalized)
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeDisplayName(scopeCode string) string {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if v, ok := airRescuerChecklistScopeDisplayNamesEN[normalized]; ok {
|
||||
return v
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeDisplayNameDE(scopeCode string) string {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if v, ok := airRescuerChecklistScopeDisplayNamesDE[normalized]; ok {
|
||||
return v
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeCodeEN(scopeCode string) string {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if v, ok := airRescuerChecklistScopeCodesEN[normalized]; ok {
|
||||
return v
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeCodeDE(scopeCode string) string {
|
||||
return normalizeAirRescuerChecklistScope(scopeCode)
|
||||
}
|
||||
|
||||
func AirRescuerChecklistPersistedScopeOrder() []string {
|
||||
out := make([]string, 0, len(airRescuerChecklistPersistedScopeOrder))
|
||||
out = append(out, airRescuerChecklistPersistedScopeOrder...)
|
||||
return out
|
||||
}
|
||||
|
||||
func IsAirRescuerChecklistScope(scopeCode string) bool {
|
||||
return IsAirRescuerChecklistPersistedScope(scopeCode)
|
||||
}
|
||||
|
||||
func normalizeAirRescuerChecklistScope(scopeCode string) string {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(scopeCode))
|
||||
if canonical, ok := airRescuerChecklistScopeAliases[normalized]; ok {
|
||||
return canonical
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
159
internal/domain/air_rescue_checklist/const_test.go
Normal file
159
internal/domain/air_rescue_checklist/const_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAirRescuerChecklistScopeHelpers(t *testing.T) {
|
||||
persistedScopes := []string{
|
||||
AirRescuerChecklistScopeTA,
|
||||
AirRescuerChecklistScopeMO,
|
||||
AirRescuerChecklistScopeDI,
|
||||
AirRescuerChecklistScopeMI,
|
||||
AirRescuerChecklistScopeDO,
|
||||
AirRescuerChecklistScopeFR,
|
||||
AirRescuerChecklistScopeSA,
|
||||
AirRescuerChecklistScopeSO,
|
||||
AirRescuerChecklistScopeM10,
|
||||
AirRescuerChecklistScopeM15,
|
||||
AirRescuerChecklistScopeM20,
|
||||
AirRescuerChecklistScopeM25,
|
||||
AirRescuerChecklistScopeDS,
|
||||
}
|
||||
|
||||
for _, scope := range persistedScopes {
|
||||
if !IsAirRescuerChecklistPersistedScope(scope) {
|
||||
t.Fatalf("expected persisted scope valid: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistPersistedScope(" " + scope + " ") {
|
||||
t.Fatalf("expected persisted scope valid with trim: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistPersistedScope(lower(scope)) {
|
||||
t.Fatalf("expected persisted scope valid with lowercase: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistQueryScope(scope) {
|
||||
t.Fatalf("expected query scope valid: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistScope(scope) {
|
||||
t.Fatalf("expected compatibility scope valid: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
if IsAirRescuerChecklistPersistedScope(AirRescuerChecklistScopeCAT) {
|
||||
t.Fatalf("expected CAT not persisted")
|
||||
}
|
||||
if !IsAirRescuerChecklistQueryScope(AirRescuerChecklistScopeCAT) {
|
||||
t.Fatalf("expected CAT valid for query")
|
||||
}
|
||||
if IsAirRescuerChecklistQueryScope("UNKNOWN") {
|
||||
t.Fatalf("expected unknown query scope invalid")
|
||||
}
|
||||
if IsAirRescuerChecklistScope("UNKNOWN") {
|
||||
t.Fatalf("expected unknown compatibility scope invalid")
|
||||
}
|
||||
|
||||
englishAliases := map[string]string{
|
||||
"daily": AirRescuerChecklistScopeTA,
|
||||
"tuesday": AirRescuerChecklistScopeDI,
|
||||
"wednesday": AirRescuerChecklistScopeMI,
|
||||
"thursday": AirRescuerChecklistScopeDO,
|
||||
"sunday": AirRescuerChecklistScopeSO,
|
||||
"deadline": AirRescuerChecklistScopeDS,
|
||||
"overview": AirRescuerChecklistScopeCAT,
|
||||
"tu": AirRescuerChecklistScopeDI,
|
||||
"we": AirRescuerChecklistScopeMI,
|
||||
"th": AirRescuerChecklistScopeDO,
|
||||
"su": AirRescuerChecklistScopeSO,
|
||||
}
|
||||
|
||||
for alias, expected := range englishAliases {
|
||||
if got := CanonicalizeAirRescuerChecklistScope(alias); got != expected {
|
||||
t.Fatalf("expected alias %s canonicalized to %s, got %s", alias, expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsAirRescuerChecklistPersistedScope("tu") {
|
||||
t.Fatalf("expected TU alias valid as persisted scope")
|
||||
}
|
||||
if !IsAirRescuerChecklistQueryScope("overview") {
|
||||
t.Fatalf("expected OVERVIEW alias valid as query scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistScopeDisplayName(t *testing.T) {
|
||||
if got := AirRescuerChecklistScopeDisplayName(AirRescuerChecklistScopeCAT); got != "Category" {
|
||||
t.Fatalf("unexpected CAT display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayName(" mo "); got != "Monday" {
|
||||
t.Fatalf("unexpected MO display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayName("x"); got != "X" {
|
||||
t.Fatalf("unexpected fallback display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayName("tu"); got != "Tuesday" {
|
||||
t.Fatalf("unexpected TU display name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistScopeDisplayNameDE(t *testing.T) {
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE(AirRescuerChecklistScopeCAT); got != "Category" {
|
||||
t.Fatalf("unexpected CAT DE display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE(" mo "); got != "Montag" {
|
||||
t.Fatalf("unexpected MO DE display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE("x"); got != "X" {
|
||||
t.Fatalf("unexpected DE fallback display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE("thursday"); got != "Donnerstag" {
|
||||
t.Fatalf("unexpected THURSDAY DE display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE(AirRescuerChecklistScopeDS); got != "Deadline" {
|
||||
t.Fatalf("unexpected DS DE display name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistScopeCodes(t *testing.T) {
|
||||
if got := AirRescuerChecklistScopeCodeEN(AirRescuerChecklistScopeTA); got != "DA" {
|
||||
t.Fatalf("unexpected TA EN code: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeCodeEN("thursday"); got != "TH" {
|
||||
t.Fatalf("unexpected THURSDAY EN code: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeCodeEN("x"); got != "X" {
|
||||
t.Fatalf("unexpected EN fallback code: %s", got)
|
||||
}
|
||||
|
||||
if got := AirRescuerChecklistScopeCodeDE(AirRescuerChecklistScopeDI); got != AirRescuerChecklistScopeDI {
|
||||
t.Fatalf("unexpected DI DE code: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeCodeDE("tu"); got != AirRescuerChecklistScopeDI {
|
||||
t.Fatalf("unexpected TU DE code: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistPersistedScopeOrderReturnsCopy(t *testing.T) {
|
||||
first := AirRescuerChecklistPersistedScopeOrder()
|
||||
if len(first) != 13 {
|
||||
t.Fatalf("unexpected persisted scope order size: %d", len(first))
|
||||
}
|
||||
if first[0] != AirRescuerChecklistScopeTA || first[len(first)-1] != AirRescuerChecklistScopeDS {
|
||||
t.Fatalf("unexpected persisted scope order: %+v", first)
|
||||
}
|
||||
|
||||
first[0] = "BROKEN"
|
||||
second := AirRescuerChecklistPersistedScopeOrder()
|
||||
if second[0] != AirRescuerChecklistScopeTA {
|
||||
t.Fatalf("expected second call to return independent copy, got: %s", second[0])
|
||||
}
|
||||
}
|
||||
|
||||
func lower(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
ch = ch + ('a' - 'A')
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
117
internal/domain/air_rescue_checklist/model.go
Normal file
117
internal/domain/air_rescue_checklist/model.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type AirRescuerChecklist struct {
|
||||
ID []byte `gorm:"column:id;type:binary(16);primaryKey"`
|
||||
HEMSBaseID []byte `gorm:"column:hems_base_id;type:binary(16);not null"`
|
||||
ScopeCode string `gorm:"column:scope_code;type:varchar(20);not null"`
|
||||
Title string `gorm:"column:title;type:varchar(150);not null"`
|
||||
Position int `gorm:"column:position;not null;default:1"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
CreatedBy []byte `gorm:"column:created_by;type:binary(16)"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
UpdatedBy []byte `gorm:"column:updated_by;type:binary(16)"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"column:deleted_by;type:binary(16)"`
|
||||
}
|
||||
|
||||
func (AirRescuerChecklist) TableName() string {
|
||||
return "air_rescuer_checklists"
|
||||
}
|
||||
|
||||
func (h *AirRescuerChecklist) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(h.ID) == 0 {
|
||||
h.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AirRescuerChecklistItem struct {
|
||||
ID []byte `gorm:"column:id;type:binary(16);primaryKey"`
|
||||
ChecklistID []byte `gorm:"column:checklist_id;type:binary(16);not null"`
|
||||
Name string `gorm:"column:name;type:varchar(255);not null"`
|
||||
Position int `gorm:"column:position;not null;default:1"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
CreatedBy []byte `gorm:"column:created_by;type:binary(16)"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
UpdatedBy []byte `gorm:"column:updated_by;type:binary(16)"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"column:deleted_by;type:binary(16)"`
|
||||
}
|
||||
|
||||
func (AirRescuerChecklistItem) TableName() string {
|
||||
return "air_rescuer_checklist_items"
|
||||
}
|
||||
|
||||
func (h *AirRescuerChecklistItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(h.ID) == 0 {
|
||||
h.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type HEMSBaseView struct {
|
||||
ID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
BaseCategory string
|
||||
Position int
|
||||
}
|
||||
|
||||
type ChecklistHeaderView struct {
|
||||
ID []byte
|
||||
HEMSBaseID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
BaseCategory string
|
||||
ScopeCode string
|
||||
Title string
|
||||
Position int
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ChecklistItemView struct {
|
||||
ID []byte
|
||||
ChecklistID []byte
|
||||
Name string
|
||||
Position int
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ChecklistHeaderFilter struct {
|
||||
HEMSBaseID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
ScopeCode string
|
||||
CategoryType string
|
||||
}
|
||||
|
||||
type HEMSBaseFilter struct {
|
||||
HEMSBaseID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
CategoryType string
|
||||
}
|
||||
|
||||
type ChecklistCreateInput struct {
|
||||
Checklist AirRescuerChecklist
|
||||
Items []AirRescuerChecklistItem
|
||||
}
|
||||
|
||||
type ChecklistItemReorderInput struct {
|
||||
ID []byte
|
||||
Position int
|
||||
}
|
||||
52
internal/domain/air_rescue_checklist/model_test.go
Normal file
52
internal/domain/air_rescue_checklist/model_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAirRescuerChecklistModelTableNameAndBeforeCreate(t *testing.T) {
|
||||
var header AirRescuerChecklist
|
||||
if got := header.TableName(); got != "air_rescuer_checklists" {
|
||||
t.Fatalf("unexpected checklist table name: %s", got)
|
||||
}
|
||||
|
||||
if err := header.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist: %v", err)
|
||||
}
|
||||
if len(header.ID) == 0 {
|
||||
t.Fatalf("expected checklist id generated")
|
||||
}
|
||||
|
||||
existingID := []byte("1234567890123456")
|
||||
header2 := AirRescuerChecklist{ID: append([]byte(nil), existingID...)}
|
||||
if err := header2.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist with existing id: %v", err)
|
||||
}
|
||||
if !bytes.Equal(header2.ID, existingID) {
|
||||
t.Fatalf("expected existing checklist id preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistItemModelTableNameAndBeforeCreate(t *testing.T) {
|
||||
var item AirRescuerChecklistItem
|
||||
if got := item.TableName(); got != "air_rescuer_checklist_items" {
|
||||
t.Fatalf("unexpected checklist item table name: %s", got)
|
||||
}
|
||||
|
||||
if err := item.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist item: %v", err)
|
||||
}
|
||||
if len(item.ID) == 0 {
|
||||
t.Fatalf("expected checklist item id generated")
|
||||
}
|
||||
|
||||
existingID := []byte("abcdefghijklmnop")
|
||||
item2 := AirRescuerChecklistItem{ID: append([]byte(nil), existingID...)}
|
||||
if err := item2.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist item with existing id: %v", err)
|
||||
}
|
||||
if !bytes.Equal(item2.ID, existingID) {
|
||||
t.Fatalf("expected existing checklist item id preserved")
|
||||
}
|
||||
}
|
||||
28
internal/domain/air_rescue_checklist/repository.go
Normal file
28
internal/domain/air_rescue_checklist/repository.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "context"
|
||||
|
||||
type TxRepository interface {
|
||||
CreateChecklist(ctx context.Context, row *AirRescuerChecklist) error
|
||||
NextChecklistPosition(ctx context.Context, hemsBaseID []byte, scopeCode string) (int, error)
|
||||
LockChecklistByID(ctx context.Context, id []byte) (*AirRescuerChecklist, error)
|
||||
UpdateChecklist(ctx context.Context, row *AirRescuerChecklist) error
|
||||
SoftDeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
SoftDeleteItemsByChecklistID(ctx context.Context, checklistID []byte, deletedBy []byte) error
|
||||
|
||||
CreateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
CreateChecklistItems(ctx context.Context, rows []AirRescuerChecklistItem) error
|
||||
NextChecklistItemPosition(ctx context.Context, checklistID []byte) (int, error)
|
||||
LockChecklistItemByID(ctx context.Context, id []byte) (*AirRescuerChecklistItem, error)
|
||||
UpdateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
SoftDeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
InTx(ctx context.Context, fn func(txRepo TxRepository) error) error
|
||||
GetChecklistByID(ctx context.Context, id []byte) (*ChecklistHeaderView, error)
|
||||
GetChecklistItemByID(ctx context.Context, id []byte) (*ChecklistItemView, error)
|
||||
ListChecklistHeaders(ctx context.Context, filter ChecklistHeaderFilter) ([]ChecklistHeaderView, error)
|
||||
ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]ChecklistItemView, error)
|
||||
ListHEMSBases(ctx context.Context, filter HEMSBaseFilter) ([]HEMSBaseView, error)
|
||||
}
|
||||
21
internal/domain/air_rescue_checklist/service.go
Normal file
21
internal/domain/air_rescue_checklist/service.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
CreateBulk(ctx context.Context, rows []ChecklistCreateInput) error
|
||||
UpdateChecklist(ctx context.Context, row *AirRescuerChecklist) error
|
||||
UpdateChecklistWithNewItems(ctx context.Context, row *AirRescuerChecklist, newItems []AirRescuerChecklistItem) error
|
||||
DeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
CreateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
CreateChecklistItems(ctx context.Context, checklistID []byte, rows []AirRescuerChecklistItem) error
|
||||
ReorderChecklistItems(ctx context.Context, checklistID []byte, rows []ChecklistItemReorderInput, updatedBy []byte) error
|
||||
UpdateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
DeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
|
||||
GetChecklistByID(ctx context.Context, id []byte) (*ChecklistHeaderView, error)
|
||||
GetChecklistItemByID(ctx context.Context, id []byte) (*ChecklistItemView, error)
|
||||
ListChecklistHeaders(ctx context.Context, filter ChecklistHeaderFilter) ([]ChecklistHeaderView, error)
|
||||
ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]ChecklistItemView, error)
|
||||
ListHEMSBases(ctx context.Context, filter HEMSBaseFilter) ([]HEMSBaseView, error)
|
||||
}
|
||||
36
internal/domain/audit/model.go
Normal file
36
internal/domain/audit/model.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
RequestID string `gorm:"type:varchar(64);index;column:request_id"`
|
||||
ActorUserID []byte `gorm:"type:binary(16);index;column:actor_user_id"`
|
||||
Layer string `gorm:"type:varchar(32);index;not null;column:layer"`
|
||||
Module string `gorm:"type:varchar(64);index;column:module"`
|
||||
Action string `gorm:"type:varchar(128);index;not null;column:action"`
|
||||
Method string `gorm:"type:varchar(12);index;column:method"`
|
||||
Path string `gorm:"type:varchar(255);index;column:path"`
|
||||
StatusCode int `gorm:"index;column:status_code"`
|
||||
Success bool `gorm:"type:tinyint(1);index;not null;default:0;column:success"`
|
||||
IP string `gorm:"type:varchar(128);column:ip"`
|
||||
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
|
||||
Message string `gorm:"type:varchar(512);column:message"`
|
||||
Metadata []byte `gorm:"type:json;column:metadata"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (AuditLog) TableName() string { return "audit_logs" }
|
||||
|
||||
func (a *AuditLog) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
internal/domain/audit/repository.go
Normal file
7
internal/domain/audit/repository.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package audit
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, entry *AuditLog) error
|
||||
}
|
||||
16
internal/domain/auth/contact_constants.go
Normal file
16
internal/domain/auth/contact_constants.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
const (
|
||||
RoleCodePilot = "pilot"
|
||||
RoleCodeDoctor = "doctor"
|
||||
RoleCodeAirRescuer = "air_rescuer"
|
||||
RoleCodeTechnician = "technician"
|
||||
RoleCodeFlightAssistant = "flight_assistant"
|
||||
RoleCodeStaff = "staff"
|
||||
)
|
||||
|
||||
const (
|
||||
PilotCategoryRegular = "regular"
|
||||
PilotCategoryFreelance = "freelance"
|
||||
PilotCategoryDryLease = "dry_lease"
|
||||
)
|
||||
141
internal/domain/auth/contact_profiles.go
Normal file
141
internal/domain/auth/contact_profiles.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// PilotProfile stores role-specific data for role=pilot.
|
||||
type PilotProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
PilotCategory string `gorm:"type:varchar(16);not null;index;column:pilot_category"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
|
||||
LicenseIssuedAt *time.Time `gorm:"column:license_issued_at"`
|
||||
LicenseExpiredAt *time.Time `gorm:"column:license_expired_at"`
|
||||
TechnicianLicenseNo string `gorm:"type:varchar(120);column:technician_license_no"`
|
||||
HasIFRQualification bool `gorm:"not null;default:false;column:has_ifr_qualification"`
|
||||
IsChiefPilot bool `gorm:"not null;default:false;column:is_chief_pilot"`
|
||||
IsDUL bool `gorm:"not null;default:false;column:is_dul"`
|
||||
TotalFlightMinutes int `gorm:"not null;default:0;column:total_flight_minutes"`
|
||||
ResponsiblePilotMinutes int `gorm:"not null;default:0;column:responsible_pilot_minutes"`
|
||||
SecondPilotMinutes int `gorm:"not null;default:0;column:second_pilot_minutes"`
|
||||
DoubleCommandMinutes int `gorm:"not null;default:0;column:double_command_minutes"`
|
||||
FlightInstructorMinutes int `gorm:"not null;default:0;column:flight_instructor_minutes"`
|
||||
NightFlightMinutes int `gorm:"not null;default:0;column:night_flight_minutes"`
|
||||
IFRFlightMinutes int `gorm:"not null;default:0;column:ifr_flight_minutes"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
WeightKG *float64 `gorm:"type:decimal(8,2);column:weight_kg"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (PilotProfile) TableName() string { return "pilot_profiles" }
|
||||
|
||||
// DoctorProfile stores role-specific data for role=doctor.
|
||||
type DoctorProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
Specialization string `gorm:"type:varchar(120);column:specialization"`
|
||||
SubSpecialization string `gorm:"type:varchar(120);column:sub_specialization"`
|
||||
IsChiefPhysician bool `gorm:"not null;default:false;column:is_chief_physician"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (DoctorProfile) TableName() string { return "doctor_profiles" }
|
||||
|
||||
// AirRescuerProfile stores role-specific data for role=air_rescuer.
|
||||
type AirRescuerProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
ResponsibleFlightRescuer bool `gorm:"not null;default:false;column:responsible_flight_rescuer"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (AirRescuerProfile) TableName() string { return "air_rescuer_profiles" }
|
||||
|
||||
// TechnicianProfile stores role-specific data for role=technician.
|
||||
type TechnicianProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
|
||||
IsChiefTechnician bool `gorm:"not null;default:false;column:is_chief_technician"`
|
||||
IsResponsibleComplaints bool `gorm:"not null;default:false;column:is_responsible_complaints"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (TechnicianProfile) TableName() string { return "technician_profiles" }
|
||||
|
||||
// FlightAssistantProfile stores role-specific data for role=flight_assistant.
|
||||
type FlightAssistantProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (FlightAssistantProfile) TableName() string { return "flight_assistant_profiles" }
|
||||
|
||||
// StaffProfile stores role-specific data for role=staff.
|
||||
type StaffProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
RoleLabel string `gorm:"type:varchar(120);column:role_label"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (StaffProfile) TableName() string { return "staff_profiles" }
|
||||
34
internal/domain/auth/email_login_otp.go
Normal file
34
internal/domain/auth/email_login_otp.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserEmailLoginOTP struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
OTPHash []byte `gorm:"type:binary(32);not null;column:otp_hash"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
UsedAt *time.Time `gorm:"index;column:used_at"`
|
||||
AttemptCount int `gorm:"not null;default:0;column:attempt_count"`
|
||||
MaxAttempts int `gorm:"not null;default:5;column:max_attempts"`
|
||||
ResendCount int `gorm:"not null;default:1;column:resend_count"`
|
||||
LastSentAt time.Time `gorm:"index;not null;column:last_sent_at"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserEmailLoginOTP) TableName() string { return "user_email_login_otps" }
|
||||
|
||||
func (o *UserEmailLoginOTP) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(o.ID) == 0 {
|
||||
o.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
143
internal/domain/auth/email_outbox.go
Normal file
143
internal/domain/auth/email_outbox.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
const (
|
||||
EmailOutboxStatusPending = "pending"
|
||||
EmailOutboxStatusProcessing = "processing"
|
||||
EmailOutboxStatusPublished = "published"
|
||||
EmailOutboxStatusDead = "dead"
|
||||
)
|
||||
|
||||
type EmailOutboxMessage struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
MessageID string `gorm:"type:varchar(64);uniqueIndex;not null;column:message_id"`
|
||||
Kind string `gorm:"type:varchar(64);index;not null;column:kind"`
|
||||
JobType string `gorm:"type:varchar(64);index;not null;column:job_type"`
|
||||
Body []byte `gorm:"type:longblob;not null;column:body"`
|
||||
Attributes []byte `gorm:"type:longblob;column:attributes"`
|
||||
MessageGroupID string `gorm:"type:varchar(128);column:message_group_id"`
|
||||
DeduplicationID string `gorm:"type:varchar(128);column:deduplication_id"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;column:status"`
|
||||
Attempts int `gorm:"not null;default:0;column:attempts"`
|
||||
AvailableAt time.Time `gorm:"index;not null;column:available_at"`
|
||||
LockedAt *time.Time `gorm:"index;column:locked_at"`
|
||||
LockedBy string `gorm:"type:varchar(64);column:locked_by"`
|
||||
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||
LastError string `gorm:"type:text;column:last_error"`
|
||||
CorrelationID string `gorm:"type:varchar(64);index;column:correlation_id"`
|
||||
TraceID string `gorm:"type:varchar(64);index;column:trace_id"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (EmailOutboxMessage) TableName() string { return "email_outbox_messages" }
|
||||
|
||||
func (m *EmailOutboxMessage) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if strings.TrimSpace(m.Status) == "" {
|
||||
m.Status = EmailOutboxStatusPending
|
||||
}
|
||||
if m.AvailableAt.IsZero() {
|
||||
m.AvailableAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewEmailOutboxMessage(now time.Time, message *queue.OutboundMessage) (*EmailOutboxMessage, error) {
|
||||
if message == nil {
|
||||
return nil, errors.New("outbound message is required")
|
||||
}
|
||||
if strings.TrimSpace(message.ID) == "" {
|
||||
return nil, errors.New("outbound message id is required")
|
||||
}
|
||||
if len(message.Body) == 0 {
|
||||
return nil, errors.New("outbound message body is required")
|
||||
}
|
||||
|
||||
attributes, err := json.Marshal(message.Attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
kind := strings.TrimSpace(message.Attributes["message_kind"])
|
||||
if kind == "" {
|
||||
kind = queue.EmailKind
|
||||
}
|
||||
jobType := strings.TrimSpace(message.Attributes["job_type"])
|
||||
if jobType == "" {
|
||||
jobType = "generic"
|
||||
}
|
||||
|
||||
return &EmailOutboxMessage{
|
||||
MessageID: strings.TrimSpace(message.ID),
|
||||
Kind: kind,
|
||||
JobType: jobType,
|
||||
Body: append([]byte(nil), message.Body...),
|
||||
Attributes: append([]byte(nil), attributes...),
|
||||
MessageGroupID: strings.TrimSpace(message.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(message.DeduplicationID),
|
||||
Status: EmailOutboxStatusPending,
|
||||
AvailableAt: now.UTC(),
|
||||
CorrelationID: strings.TrimSpace(message.Attributes["correlation_id"]),
|
||||
TraceID: strings.TrimSpace(message.Attributes["trace_id"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *EmailOutboxMessage) OutboundMessage() (*queue.OutboundMessage, error) {
|
||||
if m == nil {
|
||||
return nil, errors.New("email outbox message is required")
|
||||
}
|
||||
attributes := map[string]string{}
|
||||
if len(m.Attributes) > 0 {
|
||||
if err := json.Unmarshal(m.Attributes, &attributes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.MessageID) == "" {
|
||||
return nil, errors.New("message id is required")
|
||||
}
|
||||
if len(m.Body) == 0 {
|
||||
return nil, errors.New("message body is required")
|
||||
}
|
||||
return &queue.OutboundMessage{
|
||||
ID: m.MessageID,
|
||||
Body: append([]byte(nil), m.Body...),
|
||||
Attributes: attributes,
|
||||
MessageGroupID: strings.TrimSpace(m.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(m.DeduplicationID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type EmailOutboxWriter interface {
|
||||
CreateEmailOutboxMessage(ctx context.Context, message *EmailOutboxMessage) error
|
||||
}
|
||||
|
||||
type EmailOutboxRepository interface {
|
||||
EmailOutboxWriter
|
||||
ClaimPendingEmailOutboxMessages(ctx context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]EmailOutboxMessage, error)
|
||||
MarkEmailOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error
|
||||
MarkEmailOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error
|
||||
MarkEmailOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error
|
||||
CountPendingEmailOutboxMessages(ctx context.Context, now time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type TransactionalRepository interface {
|
||||
WithTransaction(ctx context.Context, fn func(repo Repository, outbox EmailOutboxWriter) error) error
|
||||
}
|
||||
31
internal/domain/auth/identity.go
Normal file
31
internal/domain/auth/identity.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserIdentity struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_identity_user;not null;column:user_id"`
|
||||
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_provider_subject;not null;column:provider"`
|
||||
ProviderSubject string `gorm:"type:varchar(191);uniqueIndex:ux_provider_subject;not null;column:provider_subject"`
|
||||
Email string `gorm:"type:varchar(191);index;column:email"`
|
||||
Metadata []byte `gorm:"type:json;column:metadata"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserIdentity) TableName() string { return "user_identities" }
|
||||
|
||||
func (ui *UserIdentity) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(ui.ID) == 0 {
|
||||
ui.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
40
internal/domain/auth/microsoft_oauth_token.go
Normal file
40
internal/domain/auth/microsoft_oauth_token.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserMicrosoftOAuthToken struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:user_id"`
|
||||
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:provider"`
|
||||
TenantID string `gorm:"type:varchar(128);index;not null;column:tenant_id"`
|
||||
MicrosoftObjectID string `gorm:"type:varchar(191);not null;column:microsoft_object_id"`
|
||||
MicrosoftSubject string `gorm:"type:varchar(191);not null;column:microsoft_subject"`
|
||||
MicrosoftSessionID string `gorm:"type:varchar(191);column:microsoft_session_id"`
|
||||
RefreshTokenEncrypted []byte `gorm:"type:blob;not null;column:refresh_token_encrypted"`
|
||||
AccessTokenEncrypted []byte `gorm:"type:blob;column:access_token_encrypted"`
|
||||
IDTokenEncrypted []byte `gorm:"type:blob;column:id_token_encrypted"`
|
||||
Scope string `gorm:"type:text;column:scope"`
|
||||
TokenType string `gorm:"type:varchar(32);column:token_type"`
|
||||
AccessTokenExpiresAt *time.Time `gorm:"column:access_token_expires_at"`
|
||||
RefreshTokenExpiresAt *time.Time `gorm:"column:refresh_token_expires_at"`
|
||||
RefreshTokenRotatedAt *time.Time `gorm:"column:refresh_token_rotated_at"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserMicrosoftOAuthToken) TableName() string { return "user_microsoft_oauth_tokens" }
|
||||
|
||||
func (t *UserMicrosoftOAuthToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(t.ID) == 0 {
|
||||
t.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
internal/domain/auth/password_reset_token.go
Normal file
32
internal/domain/auth/password_reset_token.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
TokenHash []byte `gorm:"type:varbinary(32);uniqueIndex;not null;column:token_hash"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
UsedAt *time.Time `gorm:"index;column:used_at"`
|
||||
RequestedIP string `gorm:"type:varchar(128);column:requested_ip"`
|
||||
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
|
||||
|
||||
func (prt *PasswordResetToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(prt.ID) == 0 {
|
||||
prt.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
internal/domain/auth/permission.go
Normal file
32
internal/domain/auth/permission.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Permission struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
|
||||
Key string `gorm:"type:varchar(64);uniqueIndex;not null;column:key"`
|
||||
Description string `gorm:"type:varchar(255);column:description"`
|
||||
Module string `gorm:"type:varchar(64);index;column:module"`
|
||||
RequiresPIN bool `gorm:"not null;default:false;column:requires_pin"`
|
||||
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "permissions" }
|
||||
|
||||
func (p *Permission) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(p.ID) == 0 {
|
||||
p.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
129
internal/domain/auth/permission_module.go
Normal file
129
internal/domain/auth/permission_module.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
ModuleUser = "user"
|
||||
ModuleRole = "role"
|
||||
ModuleHelicopter = "helicopter"
|
||||
ModuleHelicopterUsage = "helicopter_usage"
|
||||
ModuleReserveAC = "reserve_ac"
|
||||
ModuleComplaint = "complaint"
|
||||
ModuleActionSignoff = "action_signoff"
|
||||
ModuleMCF = "mcf"
|
||||
ModuleEASARelease = "easa_release"
|
||||
ModuleFlightInspection = "flight_inspection"
|
||||
ModuleAfterFlight = "after_flight"
|
||||
ModuleFlight = "flight"
|
||||
ModuleFlightPrepCheck = "flight_prep_check"
|
||||
ModuleDutyRoster = "duty_roster"
|
||||
ModuleDUL = "dul"
|
||||
ModuleHEMSOperation = "hems_operation"
|
||||
ModuleHEMSBase = "hems_base"
|
||||
ModuleOperation = "operation"
|
||||
ModuleOperationCategory = "operation_category"
|
||||
ModuleForcesPresent = "forces_present"
|
||||
ModuleAirRescuerChecklist = "air_rescuer_checklist"
|
||||
ModuleMedicine = "medicine"
|
||||
ModulePatientData = "patient_data"
|
||||
ModuleInsurancePatientData = "insurance_patient_data"
|
||||
ModuleHealthInsuranceCompanies = "health_insurance_companies"
|
||||
ModuleVocation = "vocation"
|
||||
ModuleBase = "base"
|
||||
ModuleFacility = "facility"
|
||||
ModuleCountry = "country"
|
||||
ModuleFederalState = "federal_state"
|
||||
ModuleICAO = "icao"
|
||||
ModuleNoICAOCode = "no_icao_code"
|
||||
ModuleOPC = "opc"
|
||||
ModuleMasterSettings = "master_settings"
|
||||
ModuleFileManager = "file_manager"
|
||||
ModuleFilesystem = "filesystem"
|
||||
ModuleAudit = "audit"
|
||||
)
|
||||
|
||||
type ModuleInfo struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
var ModuleRegistry = []ModuleInfo{
|
||||
{ModuleUser, "User"},
|
||||
{ModuleRole, "Role"},
|
||||
{ModuleHelicopter, "Helicopter"},
|
||||
{ModuleHelicopterUsage, "Helicopter Usage"},
|
||||
{ModuleReserveAC, "Reserve AC"},
|
||||
{ModuleComplaint, "Complaint"},
|
||||
{ModuleActionSignoff, "Action Sign-off"},
|
||||
{ModuleMCF, "MCF"},
|
||||
{ModuleEASARelease, "EASA Release"},
|
||||
{ModuleFlightInspection, "Flight Inspection"},
|
||||
{ModuleAfterFlight, "After Flight Inspection"},
|
||||
{ModuleFlight, "Flight"},
|
||||
{ModuleFlightPrepCheck, "Flight Prep Check"},
|
||||
{ModuleDutyRoster, "Duty Roster"},
|
||||
{ModuleDUL, "DUL"},
|
||||
{ModuleHEMSOperation, "HEMS Operation"},
|
||||
{ModuleHEMSBase, "HEMS Base"},
|
||||
{ModuleOperation, "Operation"},
|
||||
{ModuleOperationCategory, "Operation Category"},
|
||||
{ModuleForcesPresent, "Forces Present"},
|
||||
{ModuleAirRescuerChecklist, "Air Rescuer Checklist"},
|
||||
{ModuleMedicine, "Medicine"},
|
||||
{ModulePatientData, "Patient Data"},
|
||||
{ModuleInsurancePatientData, "Insurance Patient Data"},
|
||||
{ModuleHealthInsuranceCompanies, "Health Insurance Companies"},
|
||||
{ModuleVocation, "Vocation"},
|
||||
{ModuleBase, "Base"},
|
||||
{ModuleFacility, "Facility"},
|
||||
{ModuleCountry, "Country"},
|
||||
{ModuleFederalState, "Federal State"},
|
||||
{ModuleICAO, "ICAO"},
|
||||
{ModuleNoICAOCode, "No ICAO Code"},
|
||||
{ModuleOPC, "OPC"},
|
||||
{ModuleMasterSettings, "Master Settings"},
|
||||
{ModuleFileManager, "File Manager"},
|
||||
{ModuleFilesystem, "Filesystem"},
|
||||
{ModuleAudit, "Audit"},
|
||||
}
|
||||
|
||||
var moduleOrder = func() map[string]int {
|
||||
m := make(map[string]int, len(ModuleRegistry))
|
||||
for i := range ModuleRegistry {
|
||||
m[ModuleRegistry[i].Key] = i
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// ModuleLabel returns the display label for a module key, falling back to the
|
||||
// key itself when the module is not registered.
|
||||
func ModuleLabel(key string) string {
|
||||
if i, ok := moduleOrder[key]; ok {
|
||||
return ModuleRegistry[i].Label
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// IsRegisteredModule reports whether key is a known module in ModuleRegistry.
|
||||
func IsRegisteredModule(key string) bool {
|
||||
_, ok := moduleOrder[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ModuleSortIndex returns a module's position in ModuleRegistry, or a large
|
||||
// value (sorting unknown modules last) when it is not registered.
|
||||
func ModuleSortIndex(key string) int {
|
||||
if idx, ok := moduleOrder[key]; ok {
|
||||
return idx
|
||||
}
|
||||
return len(ModuleRegistry)
|
||||
}
|
||||
|
||||
// ModuleForKey derives the module from a permission key by taking the segment
|
||||
// before the first dot, e.g. "dul.create" -> "dul", "role.permission.read" -> "role".
|
||||
func ModuleForKey(permissionKey string) string {
|
||||
if i := strings.Index(permissionKey, "."); i >= 0 {
|
||||
return permissionKey[:i]
|
||||
}
|
||||
return permissionKey
|
||||
}
|
||||
49
internal/domain/auth/permission_module_test.go
Normal file
49
internal/domain/auth/permission_module_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModuleRegistryIsConsistent(t *testing.T) {
|
||||
seen := make(map[string]bool, len(ModuleRegistry))
|
||||
for _, m := range ModuleRegistry {
|
||||
if m.Key == "" {
|
||||
t.Errorf("registry has an entry with empty key (label %q)", m.Label)
|
||||
}
|
||||
if m.Label == "" {
|
||||
t.Errorf("module %q has an empty label", m.Key)
|
||||
}
|
||||
if seen[m.Key] {
|
||||
t.Errorf("duplicate module key %q in ModuleRegistry", m.Key)
|
||||
}
|
||||
seen[m.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleForKey(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"dul.create": "dul",
|
||||
"role.permission.read": "role",
|
||||
"file_manager.file.read": "file_manager",
|
||||
"audit.read": "audit",
|
||||
"filesystem": "filesystem",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := ModuleForKey(in); got != want {
|
||||
t.Errorf("ModuleForKey(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleLabelAndRegistration(t *testing.T) {
|
||||
if got := ModuleLabel(ModuleDUL); got != "DUL" {
|
||||
t.Errorf("ModuleLabel(%q) = %q, want %q", ModuleDUL, got, "DUL")
|
||||
}
|
||||
if got := ModuleLabel("unknown_module"); got != "unknown_module" {
|
||||
t.Errorf("ModuleLabel fallback = %q, want the key itself", got)
|
||||
}
|
||||
if !IsRegisteredModule(ModuleComplaint) {
|
||||
t.Errorf("expected %q to be a registered module", ModuleComplaint)
|
||||
}
|
||||
if IsRegisteredModule("unknown_module") {
|
||||
t.Errorf("did not expect %q to be registered", "unknown_module")
|
||||
}
|
||||
}
|
||||
24
internal/domain/auth/pin_policy.go
Normal file
24
internal/domain/auth/pin_policy.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
|
||||
var defaultPINRequiredPermissionKeys = []string{
|
||||
"audit.read",
|
||||
"easa_release.sign",
|
||||
}
|
||||
|
||||
func DefaultPINRequiredPermissionKeys() []string {
|
||||
out := make([]string, len(defaultPINRequiredPermissionKeys))
|
||||
copy(out, defaultPINRequiredPermissionKeys)
|
||||
return out
|
||||
}
|
||||
|
||||
func DefaultPermissionRequiresPIN(key string) bool {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
for i := range defaultPINRequiredPermissionKeys {
|
||||
if defaultPINRequiredPermissionKeys[i] == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
57
internal/domain/auth/repository.go
Normal file
57
internal/domain/auth/repository.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
// Users
|
||||
CreateUser(ctx context.Context, user *User) error
|
||||
GetUserByID(ctx context.Context, id []byte) (*User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetUserBySSOEmail(ctx context.Context, ssoEmail string) (*User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (*User, error)
|
||||
UpdateUserTimezone(ctx context.Context, id []byte, timezone string) error
|
||||
SetUserPassword(ctx context.Context, id []byte, passwordHash, salt []byte) error
|
||||
SetUserSecurityPIN(ctx context.Context, id []byte, pinHash []byte, setAt time.Time) error
|
||||
MarkEmailVerified(ctx context.Context, id []byte) error
|
||||
|
||||
// Password resets
|
||||
CreatePasswordResetToken(ctx context.Context, token *PasswordResetToken) error
|
||||
InvalidateActivePasswordResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error
|
||||
ConsumePasswordResetTokenAndSetPassword(ctx context.Context, tokenHash, passwordHash, salt []byte, usedAt time.Time) ([]byte, bool, error)
|
||||
|
||||
// Security PIN resets
|
||||
CreateSecurityPINResetToken(ctx context.Context, token *SecurityPINResetToken) error
|
||||
InvalidateActiveSecurityPINResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error
|
||||
GetActiveSecurityPINResetTokenUser(ctx context.Context, tokenHash []byte, now time.Time) (*User, error)
|
||||
ConsumeSecurityPINResetTokenAndSetPIN(ctx context.Context, tokenHash, pinHash []byte, usedAt time.Time) ([]byte, bool, error)
|
||||
|
||||
// Identities (SSO)
|
||||
UpsertIdentity(ctx context.Context, identity *UserIdentity) error
|
||||
CreateIdentity(ctx context.Context, identity *UserIdentity) error
|
||||
GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*UserIdentity, error)
|
||||
GetIdentityByUserID(ctx context.Context, userID []byte) (*UserIdentity, error)
|
||||
DeleteIdentityByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error)
|
||||
|
||||
// TOTP
|
||||
GetUserTOTP(ctx context.Context, userID []byte) (*UserTOTP, error)
|
||||
UpsertUserTOTP(ctx context.Context, totp *UserTOTP) error
|
||||
DisableUserTOTP(ctx context.Context, userID []byte) error
|
||||
DeleteUserTOTPSetup(ctx context.Context, userID []byte) error
|
||||
UpdateUserTOTPLastUsed(ctx context.Context, userID []byte) error
|
||||
|
||||
// Email login OTP
|
||||
GetActiveUserEmailLoginOTP(ctx context.Context, userID []byte, now time.Time) (*UserEmailLoginOTP, error)
|
||||
CountUserEmailLoginOTPSince(ctx context.Context, userID []byte, since time.Time) (int64, error)
|
||||
CreateUserEmailLoginOTP(ctx context.Context, otp *UserEmailLoginOTP) error
|
||||
InvalidateActiveUserEmailLoginOTPs(ctx context.Context, userID []byte, usedAt time.Time) error
|
||||
ConsumeUserEmailLoginOTP(ctx context.Context, userID, otpHash []byte, now time.Time) (bool, int, error)
|
||||
|
||||
// WebAuthn
|
||||
ListUserWebAuthnCredentials(ctx context.Context, userID []byte) ([]UserWebAuthnCredential, error)
|
||||
CreateUserWebAuthnCredential(ctx context.Context, credential *UserWebAuthnCredential) error
|
||||
UpdateUserWebAuthnCredential(ctx context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error
|
||||
DeleteAllUserWebAuthnCredentials(ctx context.Context, userID []byte) error
|
||||
}
|
||||
29
internal/domain/auth/role.go
Normal file
29
internal/domain/auth/role.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Code string `gorm:"type:varchar(64);uniqueIndex;column:code"`
|
||||
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
|
||||
Description string `gorm:"type:varchar(255);column:description"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (Role) TableName() string { return "roles" }
|
||||
|
||||
func (r *Role) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(r.ID) == 0 {
|
||||
r.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
internal/domain/auth/role_permission.go
Normal file
28
internal/domain/auth/role_permission.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type RolePermission struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
RoleID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:role_id"`
|
||||
PermissionID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:permission_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (RolePermission) TableName() string { return "role_permissions" }
|
||||
|
||||
func (rp *RolePermission) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(rp.ID) == 0 {
|
||||
rp.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
internal/domain/auth/role_repository.go
Normal file
20
internal/domain/auth/role_repository.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
type RoleRepository interface {
|
||||
CreateRole(ctx context.Context, role *Role) error
|
||||
UpdateRole(ctx context.Context, role *Role) error
|
||||
DeleteRole(ctx context.Context, id []byte) error
|
||||
GetRoleByID(ctx context.Context, id []byte) (*Role, error)
|
||||
GetRoleByName(ctx context.Context, name string) (*Role, error)
|
||||
HasRolePermission(ctx context.Context, roleID []byte, permissionKey string) (bool, error)
|
||||
GetPermissionByID(ctx context.Context, id []byte) (*Permission, error)
|
||||
GetPermissionByKey(ctx context.Context, key string) (*Permission, error)
|
||||
UpdatePermission(ctx context.Context, permission *Permission) error
|
||||
ListPermissionsByRoleID(ctx context.Context, roleID []byte) ([]Permission, error)
|
||||
ListPermissions(ctx context.Context, filter string, sort string, limit, offset int) ([]Permission, int64, error)
|
||||
AssignPermission(ctx context.Context, roleID, permissionID []byte) (*RolePermission, error)
|
||||
RemovePermission(ctx context.Context, roleID, permissionID []byte) error
|
||||
ListRoles(ctx context.Context, filterName string, sort string, limit, offset int) ([]Role, int64, error)
|
||||
}
|
||||
32
internal/domain/auth/security_pin_reset_token.go
Normal file
32
internal/domain/auth/security_pin_reset_token.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type SecurityPINResetToken struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
TokenHash []byte `gorm:"type:varbinary(32);uniqueIndex;not null;column:token_hash"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
UsedAt *time.Time `gorm:"index;column:used_at"`
|
||||
RequestedIP string `gorm:"type:varchar(128);column:requested_ip"`
|
||||
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (SecurityPINResetToken) TableName() string { return "security_pin_reset_tokens" }
|
||||
|
||||
func (prt *SecurityPINResetToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(prt.ID) == 0 {
|
||||
prt.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
30
internal/domain/auth/totp.go
Normal file
30
internal/domain/auth/totp.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserTOTP struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);uniqueIndex;not null;column:user_id"`
|
||||
SecretEncrypted []byte `gorm:"type:varbinary(512);not null;column:secret_encrypted"`
|
||||
Enabled bool `gorm:"type:tinyint(1);not null;default:0;column:enabled"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserTOTP) TableName() string { return "user_totp" }
|
||||
|
||||
func (ut *UserTOTP) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(ut.ID) == 0 {
|
||||
ut.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/domain/auth/user.go
Normal file
58
internal/domain/auth/user.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// Note: UUIDv7 should be generated at application layer and stored as BINARY(16).
|
||||
type User struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
RoleID []byte `gorm:"type:binary(16);index;column:role_id"`
|
||||
RoleIDs [][]byte `gorm:"-"`
|
||||
Roles []Role `gorm:"-"`
|
||||
ImageAttachmentID []byte `gorm:"type:binary(16);index;column:image_attachment_id"`
|
||||
ProfileAttachmentID []byte `gorm:"type:binary(16);index;column:profile_attachment_id"`
|
||||
|
||||
Email string `gorm:"type:varchar(191);uniqueIndex;not null;column:email"`
|
||||
SSOEmail *string `gorm:"type:varchar(191);uniqueIndex;column:sso_email"`
|
||||
Username *string `gorm:"type:varchar(100);index;column:username"`
|
||||
FirstName string `gorm:"type:varchar(100);not null;column:first_name"`
|
||||
LastName string `gorm:"type:varchar(100);not null;column:last_name"`
|
||||
MobilePhone string `gorm:"type:varchar(32);index;column:mobile_phone"`
|
||||
Timezone string `gorm:"type:varchar(64);not null;default:UTC;column:timezone"`
|
||||
SortKey *int `gorm:"index;column:sortkey"`
|
||||
PasswordHash []byte `gorm:"type:varbinary(255);column:password_hash"`
|
||||
SaltValue []byte `gorm:"type:varbinary(255);column:salt_value"`
|
||||
// Stores PIN hash records ("<salt_b64>$<hash_b64>"); legacy rows may still contain encrypted values.
|
||||
SecurityPINHash []byte `gorm:"type:varbinary(512);column:security_pin_hash"`
|
||||
SecurityPINSetAt *time.Time `gorm:"column:security_pin_set_at"`
|
||||
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at"`
|
||||
IsActive bool `gorm:"index;not null;default:true;column:is_active"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
PilotProfile *PilotProfile `gorm:"foreignKey:UserID;references:ID"`
|
||||
DoctorProfile *DoctorProfile `gorm:"foreignKey:UserID;references:ID"`
|
||||
AirRescuerProfile *AirRescuerProfile `gorm:"foreignKey:UserID;references:ID"`
|
||||
TechnicianProfile *TechnicianProfile `gorm:"foreignKey:UserID;references:ID"`
|
||||
FlightAssistantProfile *FlightAssistantProfile `gorm:"foreignKey:UserID;references:ID"`
|
||||
StaffProfile *StaffProfile `gorm:"foreignKey:UserID;references:ID"`
|
||||
ImageAttachment *filemanager.Attachment `gorm:"foreignKey:ImageAttachmentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
ProfileAttachment *filemanager.Attachment `gorm:"foreignKey:ProfileAttachmentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
func (u *User) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(u.ID) == 0 {
|
||||
u.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
internal/domain/auth/user_role.go
Normal file
28
internal/domain/auth/user_role.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserRole struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_user_role;not null;column:user_id"`
|
||||
RoleID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_user_role;not null;column:role_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserRole) TableName() string { return "user_roles" }
|
||||
|
||||
func (ur *UserRole) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(ur.ID) == 0 {
|
||||
ur.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
30
internal/domain/auth/webauthn_credential.go
Normal file
30
internal/domain/auth/webauthn_credential.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// UserWebAuthnCredential stores a passkey credential bound to a user.
|
||||
type UserWebAuthnCredential struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
Name string `gorm:"type:varchar(100);column:name"`
|
||||
CredentialID []byte `gorm:"type:varbinary(512);uniqueIndex;not null;column:credential_id"`
|
||||
CredentialJSON []byte `gorm:"type:mediumblob;not null;column:credential_json"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (UserWebAuthnCredential) TableName() string { return "user_webauthn_credentials" }
|
||||
|
||||
func (c *UserWebAuthnCredential) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(c.ID) == 0 {
|
||||
c.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
158
internal/domain/base/model.go
Normal file
158
internal/domain/base/model.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
CategoryKeyRegular = "regular"
|
||||
CategoryKeyHEMS = "hems"
|
||||
ShiftTimeTypeFixed = "FIXED"
|
||||
ShiftTimeTypeBMCT = "BMCT"
|
||||
ShiftTimeTypeECET = "ECET"
|
||||
BaseContactRoleHEMSEDC = "hems_edc"
|
||||
BaseContactRoleMedPax = "med_pax"
|
||||
BaseContactRoleResponsiblePilot = "responsible_pilot"
|
||||
)
|
||||
|
||||
func NormalizeCategoryType(raw string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", CategoryKeyRegular, "base":
|
||||
return CategoryKeyRegular, true
|
||||
case CategoryKeyHEMS, "hems-base", "hems_base":
|
||||
return CategoryKeyHEMS, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
type BaseCategory struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Key string `gorm:"type:varchar(64);not null;uniqueIndex;column:key"`
|
||||
Name string `gorm:"type:varchar(150);not null;column:name"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (BaseCategory) TableName() string { return "base_categories" }
|
||||
|
||||
func (c *BaseCategory) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(c.ID) == 0 {
|
||||
c.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Base struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
BaseName string `gorm:"type:varchar(150);not null;column:base"`
|
||||
BaseCategoryID []byte `gorm:"type:binary(16);index;column:base_category_id"`
|
||||
FotoAttachmentID []byte `gorm:"type:binary(16);index;column:foto_attachment_id"`
|
||||
BaseAbbreviation string `gorm:"type:varchar(64);column:base_abbreviation"`
|
||||
Address string `gorm:"type:varchar(255);column:address"`
|
||||
Latitude float64 `gorm:"type:decimal(10,7);not null;default:0;column:latitude"`
|
||||
Longitude float64 `gorm:"type:decimal(10,7);not null;default:0;column:longitude"`
|
||||
LandlineNumber string `gorm:"type:varchar(64);column:landline_number"`
|
||||
MobileNumber string `gorm:"type:varchar(64);column:mobile_number"`
|
||||
Email string `gorm:"type:varchar(191);column:email"`
|
||||
SortKey *int `gorm:"column:sortkey"`
|
||||
SMSAlert bool `gorm:"type:tinyint(1);not null;default:0;column:sms_alert"`
|
||||
Checklist bool `gorm:"type:tinyint(1);not null;default:0;column:checklist"`
|
||||
LegTime string `gorm:"type:varchar(64);column:leg_time"`
|
||||
DefaultStartTimeType string `gorm:"type:varchar(16);not null;default:'FIXED';column:default_start_time_type"`
|
||||
DefaultEndTimeType string `gorm:"type:varchar(16);not null;default:'FIXED';column:default_end_time_type"`
|
||||
DefaultShiftStart string `gorm:"type:time;column:default_shift_start"`
|
||||
DefaultShiftEnd string `gorm:"type:time;column:default_shift_end"`
|
||||
UTC string `gorm:"type:varchar(100);not null;default:'0';column:utc"`
|
||||
Dry bool `gorm:"type:tinyint(1);not null;default:0;column:dry"`
|
||||
ControlCenter bool `gorm:"type:tinyint(1);not null;default:0;column:control_center"`
|
||||
DUL bool `gorm:"type:tinyint(1);not null;default:0;column:dul"`
|
||||
Notes string `gorm:"type:text;column:notes"`
|
||||
IsActive bool `gorm:"type:tinyint(1);index;not null;default:1;column:is_active"`
|
||||
HEMSEDCContactIDs [][]byte `gorm:"-:all"`
|
||||
MedPaxContactIDs [][]byte `gorm:"-:all"`
|
||||
ResponsiblePilotContactIDs [][]byte `gorm:"-:all"`
|
||||
HEMSEDCs []BaseContactPerson `gorm:"-:all"`
|
||||
MedPax []BaseContactPerson `gorm:"-:all"`
|
||||
ResponsiblePilots []BaseContactPerson `gorm:"-:all"`
|
||||
OperationalShiftTimes []BaseOperationalShiftTime `gorm:"foreignKey:BaseID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
BaseCategory BaseCategory `gorm:"foreignKey:BaseCategoryID;references:ID"`
|
||||
FotoAttachment *filemanager.Attachment `gorm:"foreignKey:FotoAttachmentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (Base) TableName() string { return "bases" }
|
||||
|
||||
type BaseOperationalShiftTime struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
BaseID []byte `gorm:"type:binary(16);not null;index:idx_base_operational_shift_times_base_date,priority:1;column:base_id"`
|
||||
DateStart *time.Time `gorm:"type:date;index:idx_base_operational_shift_times_base_date,priority:2;column:date_start"`
|
||||
DateEnd *time.Time `gorm:"type:date;index:idx_base_operational_shift_times_base_date,priority:3;column:date_end"`
|
||||
StartTimeType string `gorm:"type:varchar(16);not null;default:'FIXED';column:start_time_type"`
|
||||
EndTimeType string `gorm:"type:varchar(16);not null;default:'FIXED';column:end_time_type"`
|
||||
ShiftStart string `gorm:"type:time;column:shift_start"`
|
||||
ShiftEnd string `gorm:"type:time;column:shift_end"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
|
||||
Base *Base `gorm:"foreignKey:BaseID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (BaseOperationalShiftTime) TableName() string { return "base_operational_shift_times" }
|
||||
|
||||
func (b *BaseOperationalShiftTime) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(b.ID) == 0 {
|
||||
b.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type BaseContactRole struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
BaseID []byte `gorm:"type:binary(16);not null;index;column:base_id"`
|
||||
ContactID []byte `gorm:"type:binary(16);not null;index;column:contact_id"`
|
||||
RoleCode string `gorm:"type:varchar(64);not null;index;column:role_code"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
type BaseContactPerson struct {
|
||||
ContactID []byte
|
||||
FirstName string
|
||||
LastName string
|
||||
}
|
||||
|
||||
func (BaseContactRole) TableName() string { return "base_contact_roles" }
|
||||
|
||||
func (r *BaseContactRole) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(r.ID) == 0 {
|
||||
r.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Base) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(b.ID) == 0 {
|
||||
b.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
internal/domain/base/repository.go
Normal file
11
internal/domain/base/repository.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package base
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
CreateBase(ctx context.Context, row *Base, categoryType string) error
|
||||
UpdateBase(ctx context.Context, row *Base, categoryType string) error
|
||||
DeleteBase(ctx context.Context, id []byte, categoryType string) error
|
||||
GetBaseByID(ctx context.Context, id []byte, categoryType string) (*Base, error)
|
||||
ListBases(ctx context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]Base, int64, error)
|
||||
}
|
||||
11
internal/domain/base/service.go
Normal file
11
internal/domain/base/service.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package base
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
CreateBase(ctx context.Context, row *Base, categoryType string) error
|
||||
UpdateBase(ctx context.Context, row *Base, categoryType string) error
|
||||
DeleteBase(ctx context.Context, id []byte, categoryType string) (*Base, error)
|
||||
GetBaseByID(ctx context.Context, id []byte, categoryType string) (*Base, error)
|
||||
ListBases(ctx context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]Base, int64, error)
|
||||
}
|
||||
75
internal/domain/before_flight_inspection/model.go
Normal file
75
internal/domain/before_flight_inspection/model.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package beforeflightinspection
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
FuelUnitLT = "LT"
|
||||
FuelUnitKG = "KG"
|
||||
FuelUnitPOUND = "POUND"
|
||||
)
|
||||
|
||||
type BeforeFlightInspection struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightInspectionID []byte `gorm:"type:binary(16);not null;uniqueIndex;column:flight_inspection_id"`
|
||||
OilEngineNR1Checked *bool `gorm:"type:tinyint(1);column:oil_engine_nr1_checked"`
|
||||
OilEngineNR2Checked *bool `gorm:"type:tinyint(1);column:oil_engine_nr2_checked"`
|
||||
HydraulicLHChecked *bool `gorm:"type:tinyint(1);column:hydraulic_lh_checked"`
|
||||
HydraulicRHChecked *bool `gorm:"type:tinyint(1);column:hydraulic_rh_checked"`
|
||||
OilTransmissionMGBChecked *bool `gorm:"type:tinyint(1);column:oil_transmission_mgb_checked"`
|
||||
OilTransmissionIGBChecked *bool `gorm:"type:tinyint(1);column:oil_transmission_igb_checked"`
|
||||
OilTransmissionTGBChecked *bool `gorm:"type:tinyint(1);column:oil_transmission_tgb_checked"`
|
||||
FuelAmount *float64 `gorm:"type:decimal(10,2);column:fuel_amount"`
|
||||
FuelUnit string `gorm:"type:varchar(10);check:chk_before_flight_fuel_unit,fuel_unit IS NULL OR fuel_unit IN ('LT','KG','POUND');column:fuel_unit"`
|
||||
FuelAddedAmount *float64 `gorm:"type:decimal(10,2);column:fuel_added_amount"`
|
||||
Note *string `gorm:"type:text;column:note"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (BeforeFlightInspection) TableName() string { return "before_flight_inspections" }
|
||||
|
||||
func (b *BeforeFlightInspection) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(b.ID) == 0 {
|
||||
b.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeFuelUnit(raw string) (string, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "":
|
||||
return "", true
|
||||
case FuelUnitLT:
|
||||
return FuelUnitLT, true
|
||||
case FuelUnitKG:
|
||||
return FuelUnitKG, true
|
||||
case FuelUnitPOUND:
|
||||
return FuelUnitPOUND, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
Status int
|
||||
Title string
|
||||
Pointer string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(e.Detail)
|
||||
}
|
||||
20
internal/domain/before_flight_inspection/repository.go
Normal file
20
internal/domain/before_flight_inspection/repository.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package beforeflightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Upsert(ctx context.Context, row *BeforeFlightInspection) error
|
||||
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*BeforeFlightInspection, error)
|
||||
FlightInspectionExists(ctx context.Context, flightInspectionID []byte) (bool, error)
|
||||
GetHelicopterCapabilities(ctx context.Context, flightInspectionID []byte) (*HelicopterCapabilities, error)
|
||||
}
|
||||
|
||||
type HelicopterCapabilities struct {
|
||||
NR1 bool
|
||||
NR2 bool
|
||||
LH bool
|
||||
RH bool
|
||||
MGB bool
|
||||
IGB bool
|
||||
TGB bool
|
||||
}
|
||||
22
internal/domain/before_flight_inspection/service.go
Normal file
22
internal/domain/before_flight_inspection/service.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package beforeflightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Upsert(ctx context.Context, flightInspectionID []byte, req *UpsertRequest) (*BeforeFlightInspection, error)
|
||||
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*BeforeFlightInspection, error)
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
OilEngineNR1Checked *bool `json:"oil_engine_nr1_checked"`
|
||||
OilEngineNR2Checked *bool `json:"oil_engine_nr2_checked"`
|
||||
HydraulicLHChecked *bool `json:"hydraulic_lh_checked"`
|
||||
HydraulicRHChecked *bool `json:"hydraulic_rh_checked"`
|
||||
OilTransmissionMGBChecked *bool `json:"oil_transmission_mgb_checked"`
|
||||
OilTransmissionIGBChecked *bool `json:"oil_transmission_igb_checked"`
|
||||
OilTransmissionTGBChecked *bool `json:"oil_transmission_tgb_checked"`
|
||||
FuelAmount *float64 `json:"fuel_amount"`
|
||||
FuelUnit *string `json:"fuel_unit"`
|
||||
FuelAddedAmount *float64 `json:"fuel_added_amount"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
153
internal/domain/complaint/model.go
Normal file
153
internal/domain/complaint/model.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package complaint
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightdomain "wucher/internal/domain/flight"
|
||||
helicopter "wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
MELSeverityNonMEL int8 = 0
|
||||
MELSeverityA int8 = 1
|
||||
MELSeverityB int8 = 2
|
||||
MELSeverityC int8 = 3
|
||||
MELSeverityD int8 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
StatusPendingMEL = "pending_mel"
|
||||
StatusActiveNonMEL = "active_non_mel"
|
||||
StatusActiveMEL = "active_mel"
|
||||
StatusServiced = "serviced"
|
||||
)
|
||||
|
||||
var melGraceDays = map[int8]int{
|
||||
MELSeverityA: 1,
|
||||
MELSeverityB: 3,
|
||||
MELSeverityC: 10,
|
||||
MELSeverityD: 120,
|
||||
}
|
||||
|
||||
type ListFilter struct {
|
||||
Search string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
Person string
|
||||
HelicopterID []byte
|
||||
FlightID []byte
|
||||
HoldItemsOnly bool
|
||||
}
|
||||
|
||||
func MELGraceDays(severity int8) int { return melGraceDays[severity] }
|
||||
|
||||
type Complaint struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
FlightID []byte `gorm:"type:binary(16);index;column:flight_id"`
|
||||
|
||||
Description string `gorm:"type:text;not null;column:description"`
|
||||
ReportedBy []byte `gorm:"type:binary(16);not null;column:reported_by"`
|
||||
ReportedAt time.Time `gorm:"type:datetime(3);not null;column:reported_at"`
|
||||
AircraftHoursAtReport *string `gorm:"type:varchar(20);column:aircraft_hours_at_report"`
|
||||
|
||||
MELSeverity int8 `gorm:"type:tinyint;not null;default:0;column:mel_severity"`
|
||||
MELClassifiedAt *time.Time `gorm:"type:datetime(3);column:mel_classified_at"`
|
||||
MELClassifiedBy []byte `gorm:"type:binary(16);column:mel_classified_by"`
|
||||
|
||||
IsNSR bool `gorm:"type:boolean;not null;default:false;column:is_nsr"`
|
||||
NSRDecidedAt *time.Time `gorm:"type:datetime(3);column:nsr_decided_at"`
|
||||
NSRDecidedBy []byte `gorm:"type:binary(16);column:nsr_decided_by"`
|
||||
NSRReason *string `gorm:"type:text;column:nsr_reason"`
|
||||
|
||||
ActionTaken *string `gorm:"type:text;column:action_taken"`
|
||||
AircraftHoursAtFix *string `gorm:"type:varchar(20);column:aircraft_hours_at_fix"`
|
||||
|
||||
FixedAt *time.Time `gorm:"type:datetime(3);column:fixed_at"`
|
||||
FixedByEASAID []byte `gorm:"type:binary(16);column:fixed_by_easa_id"`
|
||||
|
||||
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
|
||||
Flight *flightdomain.Flight `gorm:"foreignKey:FlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"type:datetime(3);column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"type:datetime(3);index;column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (Complaint) TableName() string { return "complaints" }
|
||||
|
||||
func (c *Complaint) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(c.ID) == 0 {
|
||||
c.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Complaint) Status() string {
|
||||
if c == nil {
|
||||
return StatusActiveNonMEL
|
||||
}
|
||||
if c.FixedAt != nil {
|
||||
return StatusServiced
|
||||
}
|
||||
// mel_severity is optional at creation; until it is classified (mel_classified_at set)
|
||||
// the complaint is "pending_mel" — its severity value (0) is a default, not "Non-MEL".
|
||||
if c.MELClassifiedAt == nil {
|
||||
return StatusPendingMEL
|
||||
}
|
||||
if c.MELSeverity > 0 {
|
||||
return StatusActiveMEL
|
||||
}
|
||||
return StatusActiveNonMEL
|
||||
}
|
||||
|
||||
func (c *Complaint) MELDeadline() *time.Time {
|
||||
if c == nil || c.MELSeverity <= 0 {
|
||||
return nil
|
||||
}
|
||||
grace := MELGraceDays(c.MELSeverity)
|
||||
if grace <= 0 || c.ReportedAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
// MEL grace is measured in days: A=1, B=3, C=10, D=120 days from report.
|
||||
deadline := c.ReportedAt.Add(time.Duration(grace) * 24 * time.Hour)
|
||||
return &deadline
|
||||
}
|
||||
|
||||
func (c *Complaint) IsMELAOG(now time.Time) bool {
|
||||
if c == nil || c.IsNSR {
|
||||
return false
|
||||
}
|
||||
deadline := c.MELDeadline()
|
||||
if deadline == nil {
|
||||
return false
|
||||
}
|
||||
return !now.Before(*deadline)
|
||||
}
|
||||
|
||||
// IsGrounding reports whether this complaint currently grounds the aircraft (AOG).
|
||||
// Returning to service requires a formal release: only a Non-Safety Release (NSR /
|
||||
// MEL deferral) or a signed EASA release (CRS, sets FixedAt) lifts the grounding.
|
||||
// Recording action_taken alone does NOT lift it — corrective work is not a release.
|
||||
//
|
||||
// A pending (not-yet-classified) complaint does NOT ground: an unassessed report is not
|
||||
// yet a grounding defect. Grounding starts at classification — NON-MEL grounds
|
||||
// immediately, MEL A–D grounds once its grace deadline lapses.
|
||||
func (c *Complaint) IsGrounding(now time.Time) bool {
|
||||
if c == nil || c.IsNSR || c.FixedAt != nil {
|
||||
return false
|
||||
}
|
||||
if c.MELClassifiedAt == nil {
|
||||
return false
|
||||
}
|
||||
if c.MELSeverity <= 0 {
|
||||
return true
|
||||
}
|
||||
return c.IsMELAOG(now)
|
||||
}
|
||||
141
internal/domain/complaint/model_test.go
Normal file
141
internal/domain/complaint/model_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package complaint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComplaintStatus(t *testing.T) {
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
want string
|
||||
}{
|
||||
{name: "nil complaint", row: nil, want: StatusActiveNonMEL},
|
||||
{name: "unclassified is pending", row: &Complaint{}, want: StatusPendingMEL},
|
||||
{name: "classified non-mel", row: &Complaint{MELSeverity: MELSeverityNonMEL, MELClassifiedAt: &now}, want: StatusActiveNonMEL},
|
||||
{name: "classified mel A", row: &Complaint{MELSeverity: MELSeverityA, MELClassifiedAt: &now}, want: StatusActiveMEL},
|
||||
{name: "fixed overrides mel", row: &Complaint{MELSeverity: MELSeverityA, MELClassifiedAt: &now, FixedAt: &now}, want: StatusServiced},
|
||||
{name: "fixed overrides default", row: &Complaint{FixedAt: &now}, want: StatusServiced},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.row.Status(); got != tc.want {
|
||||
t.Fatalf("Status() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplaintMELDeadline(t *testing.T) {
|
||||
base := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
wantNil bool
|
||||
wantTime time.Time
|
||||
}{
|
||||
{name: "non-mel", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base}, wantNil: true},
|
||||
// Grace is counted in days: A=1, D=120.
|
||||
{name: "mel A", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, wantTime: base.Add(24 * time.Hour)},
|
||||
{name: "mel D", row: &Complaint{MELSeverity: MELSeverityD, ReportedAt: base}, wantTime: base.Add(120 * 24 * time.Hour)},
|
||||
{name: "zero reported_at", row: &Complaint{MELSeverity: MELSeverityA}, wantNil: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.row.MELDeadline()
|
||||
if tc.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("MELDeadline() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || !got.Equal(tc.wantTime) {
|
||||
t.Fatalf("MELDeadline() = %v, want %v", got, tc.wantTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplaintIsGrounding(t *testing.T) {
|
||||
base := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
|
||||
fixed := base
|
||||
classified := base
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{name: "nsr never grounds", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, IsNSR: true}, now: base, want: false},
|
||||
{name: "fixed never grounds", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, FixedAt: &fixed}, now: base, want: false},
|
||||
{name: "pending (unclassified) does not ground", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base}, now: base, want: false},
|
||||
{name: "action taken alone does not lift grounding (needs EASA release)", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, ActionTaken: ptr("tightened")}, now: base, want: true},
|
||||
{name: "action taken with EASA fix lifts grounding", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified, ActionTaken: ptr("tightened"), FixedAt: &fixed}, now: base, want: false},
|
||||
{name: "non-mel grounds immediately", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base, MELClassifiedAt: &classified}, now: base, want: true},
|
||||
{name: "mel within grace not grounding", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base, MELClassifiedAt: &classified}, now: base.Add(12 * time.Hour), want: false},
|
||||
{name: "mel past grace grounds", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base, MELClassifiedAt: &classified}, now: base.Add(2 * 24 * time.Hour), want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.row.IsGrounding(tc.now); got != tc.want {
|
||||
t.Fatalf("IsGrounding() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplaintIsMELAOG(t *testing.T) {
|
||||
base := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
row *Complaint
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{name: "nsr", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base, IsNSR: true}, now: base.Add(time.Hour), want: false},
|
||||
{name: "non-mel has no deadline", row: &Complaint{MELSeverity: MELSeverityNonMEL, ReportedAt: base}, now: base.Add(time.Hour), want: false},
|
||||
{name: "before deadline", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, now: base, want: false},
|
||||
{name: "at deadline", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, now: base.Add(24 * time.Hour), want: true},
|
||||
{name: "after deadline", row: &Complaint{MELSeverity: MELSeverityA, ReportedAt: base}, now: base.Add(5 * 24 * time.Hour), want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.row.IsMELAOG(tc.now); got != tc.want {
|
||||
t.Fatalf("IsMELAOG() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
func TestComplaintTableNameAndBeforeCreate(t *testing.T) {
|
||||
if (Complaint{}).TableName() != "complaints" {
|
||||
t.Fatalf("unexpected table name")
|
||||
}
|
||||
c := &Complaint{}
|
||||
if err := c.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate err: %v", err)
|
||||
}
|
||||
if len(c.ID) == 0 {
|
||||
t.Fatal("BeforeCreate should set ID")
|
||||
}
|
||||
prev := string(c.ID)
|
||||
if err := c.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate err: %v", err)
|
||||
}
|
||||
if string(c.ID) != prev {
|
||||
t.Fatal("BeforeCreate must not overwrite an existing ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMELGraceDays(t *testing.T) {
|
||||
if MELGraceDays(MELSeverityA) != 1 || MELGraceDays(MELSeverityD) != 120 {
|
||||
t.Fatal("unexpected grace days")
|
||||
}
|
||||
if MELGraceDays(MELSeverityNonMEL) != 0 {
|
||||
t.Fatal("non-mel has no grace")
|
||||
}
|
||||
}
|
||||
19
internal/domain/complaint/repository.go
Normal file
19
internal/domain/complaint/repository.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package complaint
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *Complaint) error
|
||||
Update(ctx context.Context, row *Complaint) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Complaint, error)
|
||||
ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]Complaint, int64, error)
|
||||
List(ctx context.Context, filter ListFilter, sort string, limit, offset int) ([]Complaint, int64, error)
|
||||
HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error)
|
||||
ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
HelicopterHasUnactionedComplaint(ctx context.Context, helicopterID []byte) (bool, 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
|
||||
}
|
||||
20
internal/domain/complaint/service.go
Normal file
20
internal/domain/complaint/service.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package complaint
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *Complaint) error
|
||||
Update(ctx context.Context, row *Complaint) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Complaint, error)
|
||||
ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]Complaint, int64, error)
|
||||
List(ctx context.Context, filter ListFilter, sort string, limit, offset int) ([]Complaint, int64, error)
|
||||
HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error)
|
||||
ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]Complaint, error)
|
||||
HelicopterHasUnactionedComplaint(ctx context.Context, helicopterID []byte) (bool, 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
|
||||
ResolveUserNames(ctx context.Context, complaints []Complaint) (map[string]string, error)
|
||||
}
|
||||
5
internal/domain/contact/errors.go
Normal file
5
internal/domain/contact/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package contact
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrUsernameAlreadyExists = errors.New("username already exists")
|
||||
190
internal/domain/contact/model.go
Normal file
190
internal/domain/contact/model.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package contact
|
||||
|
||||
import "time"
|
||||
|
||||
type ListFilter struct {
|
||||
Role string
|
||||
PilotCategory string
|
||||
ChiefDoctor *bool
|
||||
ChiefTechnician *bool
|
||||
ResponsibleComplaint *bool
|
||||
ChiefPilot *bool
|
||||
IFRQualified *bool
|
||||
Search string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
Email *string
|
||||
SSOEmail *string
|
||||
IsAdmin *bool
|
||||
Username *string
|
||||
FirstName *string
|
||||
LastName *string
|
||||
MobilePhone *string
|
||||
Timezone *string
|
||||
SortKey *int
|
||||
SortKeySet bool
|
||||
IsActive *bool
|
||||
ProfileAttachmentID *[]byte
|
||||
}
|
||||
|
||||
type ProfilePatch struct {
|
||||
PilotCategory *string
|
||||
ShortName *string
|
||||
LicenseNo *string
|
||||
LicenseIssuedAt *time.Time
|
||||
LicenseExpiredAt *time.Time
|
||||
TechnicianLicenseNo *string
|
||||
HasIFRQualification *bool
|
||||
IsChiefPilot *bool
|
||||
IsDUL *bool
|
||||
DULBaseIDs [][]byte
|
||||
DULBaseIDsSet bool
|
||||
ResponsibleFlightRescuer *bool
|
||||
TotalFlightMinutes *int
|
||||
ResponsiblePilotMinutes *int
|
||||
SecondPilotMinutes *int
|
||||
DoubleCommandMinutes *int
|
||||
FlightInstructorMinutes *int
|
||||
NightFlightMinutes *int
|
||||
IFRFlightMinutes *int
|
||||
Specialization *string
|
||||
SubSpecialization *string
|
||||
IsChiefPhysician *bool
|
||||
IsChiefTechnician *bool
|
||||
IsResponsibleComplaints *bool
|
||||
RoleLabel *string
|
||||
Note *string
|
||||
Location *string
|
||||
Postcode *string
|
||||
StreetLine *string
|
||||
WeightKG *float64
|
||||
PhotoFileID *string
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
RoleID []byte
|
||||
RoleIDs [][]byte
|
||||
User UserPatch
|
||||
Profile ProfilePatch
|
||||
Profiles []CreateRoleProfile
|
||||
}
|
||||
|
||||
type CreateRoleProfile struct {
|
||||
RoleCode string
|
||||
Patch ProfilePatch
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
UserID []byte
|
||||
User UserPatch
|
||||
RoleID []byte
|
||||
RoleIDs [][]byte
|
||||
UpdateRoles bool
|
||||
Profile ProfilePatch
|
||||
Profiles []CreateRoleProfile
|
||||
TargetProfileRole string
|
||||
}
|
||||
|
||||
type ContactListItem struct {
|
||||
UserID []byte `gorm:"column:user_id"`
|
||||
CreatedBy []byte `gorm:"column:created_by"`
|
||||
UpdatedBy []byte `gorm:"column:updated_by"`
|
||||
RoleCode string `gorm:"column:role_code"`
|
||||
RoleName string `gorm:"column:role_name"`
|
||||
PilotCategory string `gorm:"column:pilot_category"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email"`
|
||||
SSOEmail *string `gorm:"column:sso_email"`
|
||||
IsAdmin bool `gorm:"column:is_admin"`
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
LastName string `gorm:"column:last_name"`
|
||||
ShortName string `gorm:"column:short_name"`
|
||||
LicenseNo string `gorm:"column:license_no"`
|
||||
Location string `gorm:"column:location"`
|
||||
MobilePhone string `gorm:"column:mobile_phone"`
|
||||
ProfileAttachmentID []byte `gorm:"column:profile_attachment_id"`
|
||||
HasPassword bool `gorm:"column:has_password"`
|
||||
SortKey *int `gorm:"column:sortkey"`
|
||||
Note string `gorm:"column:note"`
|
||||
IsActive bool `gorm:"column:is_active"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
LastLoginAt *time.Time `gorm:"column:last_login_at"`
|
||||
BaseRolesRaw *string `gorm:"column:base_roles_raw"`
|
||||
RolesRaw *string `gorm:"column:roles_raw"`
|
||||
|
||||
PilotShortName *string `gorm:"column:pilot_short_name"`
|
||||
PilotLicenseNo *string `gorm:"column:pilot_license_no"`
|
||||
PilotLicenseIssuedAt *time.Time `gorm:"column:pilot_license_issued_at"`
|
||||
PilotLicenseExpiredAt *time.Time `gorm:"column:pilot_license_expired_at"`
|
||||
PilotTechnicianLicenseNo *string `gorm:"column:pilot_technician_license_no"`
|
||||
PilotHasIFRQualification *bool `gorm:"column:pilot_has_ifr_qualification"`
|
||||
PilotIsChiefPilot *bool `gorm:"column:pilot_is_chief_pilot"`
|
||||
PilotIsDUL *bool `gorm:"column:pilot_is_dul"`
|
||||
PilotTotalFlightMinutes *int `gorm:"column:pilot_total_flight_minutes"`
|
||||
PilotResponsiblePilotMinutes *int `gorm:"column:pilot_responsible_pilot_minutes"`
|
||||
PilotSecondPilotMinutes *int `gorm:"column:pilot_second_pilot_minutes"`
|
||||
PilotDoubleCommandMinutes *int `gorm:"column:pilot_double_command_minutes"`
|
||||
PilotFlightInstructorMinutes *int `gorm:"column:pilot_flight_instructor_minutes"`
|
||||
PilotNightFlightMinutes *int `gorm:"column:pilot_night_flight_minutes"`
|
||||
PilotIFRFlightMinutes *int `gorm:"column:pilot_ifr_flight_minutes"`
|
||||
PilotLocation *string `gorm:"column:pilot_location"`
|
||||
PilotPostcode *string `gorm:"column:pilot_postcode"`
|
||||
PilotStreetLine *string `gorm:"column:pilot_street_line"`
|
||||
PilotNote *string `gorm:"column:pilot_note"`
|
||||
PilotWeightKG *float64 `gorm:"column:pilot_weight_kg"`
|
||||
PilotPhotoFileID *string `gorm:"column:pilot_photo_file_id"`
|
||||
|
||||
DoctorShortName *string `gorm:"column:doctor_short_name"`
|
||||
DoctorSpecialization *string `gorm:"column:doctor_specialization"`
|
||||
DoctorSubSpecialization *string `gorm:"column:doctor_sub_specialization"`
|
||||
DoctorIsChiefPhysician *bool `gorm:"column:doctor_is_chief_physician"`
|
||||
DoctorLocation *string `gorm:"column:doctor_location"`
|
||||
DoctorPostcode *string `gorm:"column:doctor_postcode"`
|
||||
DoctorStreetLine *string `gorm:"column:doctor_street_line"`
|
||||
DoctorNote *string `gorm:"column:doctor_note"`
|
||||
DoctorPhotoFileID *string `gorm:"column:doctor_photo_file_id"`
|
||||
|
||||
AirRescuerShortName *string `gorm:"column:air_rescuer_short_name"`
|
||||
AirRescuerResponsibleFlightRescuer *bool `gorm:"column:air_rescuer_responsible_flight_rescuer"`
|
||||
AirRescuerLocation *string `gorm:"column:air_rescuer_location"`
|
||||
AirRescuerPostcode *string `gorm:"column:air_rescuer_postcode"`
|
||||
AirRescuerStreetLine *string `gorm:"column:air_rescuer_street_line"`
|
||||
AirRescuerNote *string `gorm:"column:air_rescuer_note"`
|
||||
AirRescuerPhotoFileID *string `gorm:"column:air_rescuer_photo_file_id"`
|
||||
|
||||
TechnicianShortName *string `gorm:"column:technician_short_name"`
|
||||
TechnicianLicenseNo *string `gorm:"column:technician_license_no"`
|
||||
TechnicianIsChiefTechnician *bool `gorm:"column:technician_is_chief_technician"`
|
||||
TechnicianIsResponsibleComplaints *bool `gorm:"column:technician_is_responsible_complaints"`
|
||||
TechnicianLocation *string `gorm:"column:technician_location"`
|
||||
TechnicianPostcode *string `gorm:"column:technician_postcode"`
|
||||
TechnicianStreetLine *string `gorm:"column:technician_street_line"`
|
||||
TechnicianNote *string `gorm:"column:technician_note"`
|
||||
TechnicianPhotoFileID *string `gorm:"column:technician_photo_file_id"`
|
||||
|
||||
FlightAssistantShortName *string `gorm:"column:flight_assistant_short_name"`
|
||||
FlightAssistantLocation *string `gorm:"column:flight_assistant_location"`
|
||||
FlightAssistantPostcode *string `gorm:"column:flight_assistant_postcode"`
|
||||
FlightAssistantStreetLine *string `gorm:"column:flight_assistant_street_line"`
|
||||
FlightAssistantNote *string `gorm:"column:flight_assistant_note"`
|
||||
FlightAssistantPhotoFileID *string `gorm:"column:flight_assistant_photo_file_id"`
|
||||
|
||||
StaffShortName *string `gorm:"column:staff_short_name"`
|
||||
StaffRoleLabel *string `gorm:"column:staff_role_label"`
|
||||
StaffLocation *string `gorm:"column:staff_location"`
|
||||
StaffPostcode *string `gorm:"column:staff_postcode"`
|
||||
StaffStreetLine *string `gorm:"column:staff_street_line"`
|
||||
StaffNote *string `gorm:"column:staff_note"`
|
||||
StaffPhotoFileID *string `gorm:"column:staff_photo_file_id"`
|
||||
}
|
||||
|
||||
type ContactBaseRole struct {
|
||||
BaseID []byte
|
||||
BaseName string
|
||||
RoleCode string
|
||||
}
|
||||
20
internal/domain/contact/repository.go
Normal file
20
internal/domain/contact/repository.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package contact
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
ListContacts(ctx context.Context, filter ListFilter) ([]ContactListItem, int64, error)
|
||||
GetContactByID(ctx context.Context, userID []byte) (*ContactListItem, error)
|
||||
IsActorAdmin(ctx context.Context) (bool, error)
|
||||
ResolveRoleCodeByID(ctx context.Context, roleID []byte) (string, error)
|
||||
CreateContact(ctx context.Context, in CreateInput) ([]byte, error)
|
||||
UpdateContact(ctx context.Context, in UpdateInput) error
|
||||
DeleteContact(ctx context.Context, userID []byte) error
|
||||
UpdateContactStatus(ctx context.Context, userID []byte, isActive bool) error
|
||||
UpdateContactRole(ctx context.Context, userID []byte, roleCode string) error
|
||||
UpdatePilotCategory(ctx context.Context, userID []byte, category string) error
|
||||
UpdateLicense(ctx context.Context, userID []byte, licenseNo *string) error
|
||||
UpdateChiefFlags(ctx context.Context, userID []byte, patch ProfilePatch) error
|
||||
UpdateResponsibleComplaints(ctx context.Context, userID []byte, value bool) error
|
||||
BulkUpdateStatus(ctx context.Context, userIDs [][]byte, isActive bool) (int64, error)
|
||||
}
|
||||
21
internal/domain/contact/service.go
Normal file
21
internal/domain/contact/service.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package contact
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
ListContacts(ctx context.Context, filter ListFilter) ([]ContactListItem, int64, error)
|
||||
GetContactByID(ctx context.Context, userID []byte) (*ContactListItem, error)
|
||||
IsActorAdmin(ctx context.Context) (bool, error)
|
||||
ResolveRoleCodeByID(ctx context.Context, roleID []byte) (string, error)
|
||||
CreateContact(ctx context.Context, in CreateInput) ([]byte, error)
|
||||
UpdateContact(ctx context.Context, in UpdateInput) error
|
||||
DeleteContact(ctx context.Context, userID []byte) error
|
||||
UpdateContactStatus(ctx context.Context, userID []byte, isActive bool) error
|
||||
UpdateContactRole(ctx context.Context, userID []byte, roleCode string) error
|
||||
UpdatePilotCategory(ctx context.Context, userID []byte, category string) error
|
||||
UpdateLicense(ctx context.Context, userID []byte, licenseNo *string) error
|
||||
UpdateChiefFlags(ctx context.Context, userID []byte, patch ProfilePatch) error
|
||||
UpdateResponsibleComplaints(ctx context.Context, userID []byte, value bool) error
|
||||
BulkUpdateStatus(ctx context.Context, userIDs [][]byte, isActive bool) (int64, error)
|
||||
CheckSensitiveAction(ctx context.Context, userID []byte, actionToken string, action string) error
|
||||
}
|
||||
40
internal/domain/dul/model.go
Normal file
40
internal/domain/dul/model.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package dul
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
|
||||
"wucher/internal/domain/base"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type DUL struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
No uint64 `gorm:"type:bigint unsigned;not null;autoIncrement;uniqueIndex;column:no"`
|
||||
Name string `gorm:"type:varchar(128);not null;column:name"`
|
||||
Date time.Time `gorm:"type:date;not null;column:date"`
|
||||
Info string `gorm:"type:text;column:info"`
|
||||
|
||||
BaseID []byte `gorm:"type:binary(16);column:base_id"`
|
||||
Base *base.Base `gorm:"foreignKey:BaseID;references:ID"`
|
||||
|
||||
Images []*filemanager.Attachment `gorm:"-"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (DUL) TableName() string { return "duls" }
|
||||
|
||||
func (f *DUL) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
15
internal/domain/dul/repository.go
Normal file
15
internal/domain/dul/repository.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package dul
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *DUL) error
|
||||
Update(ctx context.Context, row *DUL) error
|
||||
Delete(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*DUL, error)
|
||||
List(ctx context.Context, filter, sort string, limit, offset int, baseIDs [][]byte) ([]DUL, int64, error)
|
||||
}
|
||||
|
||||
type TransactionalRepository interface {
|
||||
WithTransaction(ctx context.Context, fn func(repo Repository) error) error
|
||||
}
|
||||
11
internal/domain/dul/service.go
Normal file
11
internal/domain/dul/service.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package dul
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *DUL) error
|
||||
Update(ctx context.Context, row *DUL) error
|
||||
Delete(ctx context.Context, id []byte, actorID []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*DUL, error)
|
||||
List(ctx context.Context, filter, sort string, limit, offset int, baseIDs [][]byte) ([]DUL, int64, error)
|
||||
}
|
||||
142
internal/domain/duty_roster/model.go
Normal file
142
internal/domain/duty_roster/model.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package dutyroster
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/base"
|
||||
"wucher/internal/domain/takeover"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// DutyRoster stores roster header per base and duty date.
|
||||
type DutyRoster struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightID []byte `gorm:"type:binary(16);index:idx_roster_flight_id,unique;column:flight_id"`
|
||||
BaseID []byte `gorm:"type:binary(16);not null;index:idx_roster_base_date;column:base_id"`
|
||||
DutyDate time.Time `gorm:"type:date;not null;index:idx_roster_base_date;column:duty_date"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
|
||||
Base *base.Base `gorm:"foreignKey:BaseID;references:ID;-:migration"`
|
||||
Crews []DutyRosterCrew `gorm:"foreignKey:RosterID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (DutyRoster) TableName() string { return "duty_rosters" }
|
||||
|
||||
func (h *DutyRoster) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(h.ID) == 0 {
|
||||
h.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DutyRosterCrew struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
RosterID []byte `gorm:"type:binary(16);not null;index:idx_roster_crew_role;index:idx_roster_crew_unique,unique;column:roster_id"`
|
||||
UserID []byte `gorm:"type:binary(16);index:idx_roster_crew_unique,unique;column:user_id"`
|
||||
TakeoverAcID []byte `gorm:"type:binary(16);index;column:takeover_ac_id"`
|
||||
RoleCode string `gorm:"type:varchar(64);not null;index:idx_roster_crew_role;index:idx_roster_crew_unique,unique;check:chk_roster_crew_role_code,role_code IN ('pilot','doctor','rescuer','helper','other_person','air_rescuer','flight_assistant');column:role_code"`
|
||||
CrewType string `gorm:"type:varchar(32);not null;default:main;index:idx_roster_crew_unique,unique;check:chk_roster_crew_type,crew_type IN ('main','additional');column:crew_type"`
|
||||
NameLabel string `gorm:"type:varchar(191);index:idx_roster_crew_unique,unique;column:name_label"`
|
||||
MobilePhone string `gorm:"type:varchar(64);column:mobile_phone"`
|
||||
Email string `gorm:"type:varchar(191);column:email"`
|
||||
ConfirmAt *time.Time `gorm:"column:confirm_at"`
|
||||
DateStart *time.Time `gorm:"type:date;index:idx_roster_crew_unique,unique;check:chk_roster_crew_date_range,date_start IS NULL OR date_end IS NULL OR date_start <= date_end;column:date_start"`
|
||||
DateEnd *time.Time `gorm:"type:date;index:idx_roster_crew_unique,unique;column:date_end"`
|
||||
ShiftStart string `gorm:"type:time;index:idx_roster_crew_unique,unique;column:shift_start"`
|
||||
ShiftEnd string `gorm:"type:time;index:idx_roster_crew_unique,unique;column:shift_end"`
|
||||
FlightInstructor bool `gorm:"type:tinyint(1);not null;default:0;column:flight_instructor"`
|
||||
LineChecker bool `gorm:"type:tinyint(1);not null;default:0;column:line_checker"`
|
||||
Supervisor bool `gorm:"type:tinyint(1);not null;default:0;column:supervisor"`
|
||||
Examiner bool `gorm:"type:tinyint(1);not null;default:0;column:examiner"`
|
||||
CoPilot bool `gorm:"type:tinyint(1);not null;default:0;column:co_pilot"`
|
||||
|
||||
Roster *DutyRoster `gorm:"foreignKey:RosterID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
User *auth.User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
TakeoverAc *takeover.TakeoverAc `gorm:"foreignKey:TakeoverAcID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
|
||||
}
|
||||
|
||||
func (DutyRosterCrew) TableName() string { return "duty_roster_crews" }
|
||||
|
||||
func (h *DutyRosterCrew) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(h.ID) == 0 {
|
||||
h.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HeaderView is a denormalized header row for list/get responses.
|
||||
type HeaderView struct {
|
||||
ID []byte
|
||||
BaseID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
DefaultShiftStart string
|
||||
DefaultShiftEnd string
|
||||
HelicopterID []byte
|
||||
HelicopterType string
|
||||
HelicopterDesignation string
|
||||
HelicopterIdentifier string
|
||||
DutyDate time.Time
|
||||
ShiftStart string
|
||||
ShiftEnd string
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BaseView is a lightweight base projection used in roster listing.
|
||||
type BaseView struct {
|
||||
ID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
}
|
||||
|
||||
// CrewView is a denormalized crew row for list/get responses.
|
||||
type CrewView struct {
|
||||
ID []byte
|
||||
RosterID []byte
|
||||
UserID []byte
|
||||
Name string
|
||||
Role string
|
||||
RoleCode string
|
||||
CrewType string
|
||||
MobilePhone string
|
||||
Email string
|
||||
DateStart *time.Time
|
||||
DateEnd *time.Time
|
||||
ShiftStart string
|
||||
ShiftEnd string
|
||||
FlightInstructor bool
|
||||
LineChecker bool
|
||||
Supervisor bool
|
||||
Examiner bool
|
||||
CoPilot bool
|
||||
}
|
||||
|
||||
type DeletedCrewResult struct {
|
||||
ID []byte
|
||||
RosterID []byte
|
||||
UserID []byte
|
||||
Name string
|
||||
RoleCode string
|
||||
CrewType string
|
||||
DateStart *time.Time
|
||||
DateEnd *time.Time
|
||||
}
|
||||
38
internal/domain/duty_roster/repository.go
Normal file
38
internal/domain/duty_roster/repository.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package dutyroster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TxRepository interface {
|
||||
GetRosterByID(ctx context.Context, id []byte) (*DutyRoster, error)
|
||||
LockRosterByID(ctx context.Context, id []byte) (*DutyRoster, error)
|
||||
CreateHeader(ctx context.Context, row *DutyRoster) error
|
||||
UpdateHeader(ctx context.Context, row *DutyRoster) error
|
||||
SoftDeleteHeader(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
SoftDeleteCrewsByRosterID(ctx context.Context, rosterID []byte, deletedBy []byte) error
|
||||
ListActiveCrewsByRosterID(ctx context.Context, rosterID []byte) ([]DutyRosterCrew, error)
|
||||
InsertCrew(ctx context.Context, row *DutyRosterCrew) error
|
||||
CrewExists(ctx context.Context, row DutyRosterCrew) (bool, error)
|
||||
SoftDeleteCrewByUser(ctx context.Context, rosterID []byte, roleCode, crewType string, userID, updatedBy []byte) error
|
||||
FindRosterIDByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([]byte, error)
|
||||
FindRosterIDsByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([][]byte, error)
|
||||
MatchCrewByFilter(ctx context.Context, rosterIDs [][]byte, crew DutyRosterCrew) ([]DeletedCrewResult, error)
|
||||
SoftDeleteCrewRows(ctx context.Context, rows []DeletedCrewResult, deletedBy []byte) error
|
||||
SoftDeleteCrewForDate(ctx context.Context, baseID []byte, dutyDate time.Time, crew DutyRosterCrew, updatedBy []byte) error
|
||||
HardDeleteHeader(ctx context.Context, id []byte) error
|
||||
HardDeleteCrewsByRosterID(ctx context.Context, rosterID []byte) error
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
InTx(ctx context.Context, fn func(txRepo TxRepository) error) error
|
||||
GetBaseDefaultShift(ctx context.Context, baseID []byte, baseType string) (string, error)
|
||||
GetHeaderByID(ctx context.Context, id []byte) (*HeaderView, error)
|
||||
GetHeaderByIDByBaseType(ctx context.Context, id []byte, baseType string) (*HeaderView, error)
|
||||
GetCrewsByRosterIDs(ctx context.Context, rosterIDs [][]byte) ([]CrewView, error)
|
||||
ListHeadersByRange(ctx context.Context, baseID []byte, from, to time.Time) ([]HeaderView, error)
|
||||
ListHeadersByRangeByBaseType(ctx context.Context, baseID []byte, from, to time.Time, baseType string) ([]HeaderView, error)
|
||||
ListHEMSBases(ctx context.Context, baseID []byte) ([]BaseView, error)
|
||||
ListBasesByType(ctx context.Context, baseID []byte, baseType string) ([]BaseView, error)
|
||||
}
|
||||
23
internal/domain/duty_roster/service.go
Normal file
23
internal/domain/duty_roster/service.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package dutyroster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *DutyRoster, crews []DutyRosterCrew) error
|
||||
Update(ctx context.Context, row *DutyRoster, crews []DutyRosterCrew) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
HardDelete(ctx context.Context, id []byte) error
|
||||
DeleteCrews(ctx context.Context, id []byte, crews []DutyRosterCrew, deletedBy []byte) ([]DeletedCrewResult, error)
|
||||
GetBaseDefaultShift(ctx context.Context, baseID []byte, baseType string) (string, error)
|
||||
ResolveBaseDefaultShiftRange(ctx context.Context, baseID []byte, baseType string) (string, string, error)
|
||||
GetHeaderByID(ctx context.Context, id []byte) (*HeaderView, error)
|
||||
GetHeaderByIDByBaseType(ctx context.Context, id []byte, baseType string) (*HeaderView, error)
|
||||
GetCrewsByRosterIDs(ctx context.Context, rosterIDs [][]byte) ([]CrewView, error)
|
||||
ListHeadersByRange(ctx context.Context, baseID []byte, from, to time.Time) ([]HeaderView, error)
|
||||
ListHeadersByRangeByBaseType(ctx context.Context, baseID []byte, from, to time.Time, baseType string) ([]HeaderView, error)
|
||||
ListHEMSBases(ctx context.Context, baseID []byte) ([]BaseView, error)
|
||||
ListBasesByType(ctx context.Context, baseID []byte, baseType string) ([]BaseView, error)
|
||||
}
|
||||
9
internal/domain/easa_release/errors.go
Normal file
9
internal/domain/easa_release/errors.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package easarelease
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("easa release not found")
|
||||
ErrAlreadySigned = errors.New("easa release already signed")
|
||||
ErrIncompleteForSign = errors.New("easa release required fields are incomplete")
|
||||
)
|
||||
77
internal/domain/easa_release/model.go
Normal file
77
internal/domain/easa_release/model.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package easarelease
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
complaint "wucher/internal/domain/complaint"
|
||||
flightdomain "wucher/internal/domain/flight"
|
||||
helicopter "wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type EASARelease struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
|
||||
// ComplaintID scopes the release to the specific complaint it closes (optional).
|
||||
// When set, the release is fetched via get-by-complaint and closes that complaint.
|
||||
// When NULL the release is standalone (aircraft cleared with no open defect).
|
||||
ComplaintID []byte `gorm:"type:binary(16);index;column:complaint_id"`
|
||||
|
||||
// FlightID — optional flight context. Retained for the standalone (no-complaint)
|
||||
// sign-off gate, which is still flight-scoped. Not required for complaint releases.
|
||||
FlightID []byte `gorm:"type:binary(16);index;column:flight_id"`
|
||||
|
||||
EASADate *time.Time `gorm:"type:date;column:easa_date"`
|
||||
EASAACHours *string `gorm:"type:varchar(20);column:easa_ac_hours"`
|
||||
EASALocation *string `gorm:"type:varchar(255);column:easa_location"`
|
||||
EASAWONo *string `gorm:"type:varchar(100);column:easa_wo_no"`
|
||||
EASAMaintManualRevAirframe *string `gorm:"type:varchar(100);column:easa_maint_manual_rev_airframe"`
|
||||
EASAMaintManualRevEngine *string `gorm:"type:varchar(100);column:easa_maint_manual_rev_engine"`
|
||||
EASASignerID *string `gorm:"type:varchar(100);column:easa_signer_id"`
|
||||
EASAPermitNo *string `gorm:"type:varchar(100);column:easa_permit_no"`
|
||||
|
||||
SignedAt *time.Time `gorm:"type:datetime(3);column:signed_at"`
|
||||
SignedBy []byte `gorm:"type:binary(16);column:signed_by"`
|
||||
|
||||
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
|
||||
Flight *flightdomain.Flight `gorm:"foreignKey:FlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
Complaint *complaint.Complaint `gorm:"foreignKey:ComplaintID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"type:datetime(3);column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"type:datetime(3);index;column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (EASARelease) TableName() string { return "easa_releases" }
|
||||
|
||||
func (e *EASARelease) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(e.ID) == 0 {
|
||||
e.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EASARelease) IsSigned() bool { return e != nil && e.SignedAt != nil }
|
||||
|
||||
func (e *EASARelease) IsActive() bool { return e.IsSigned() }
|
||||
|
||||
// MissingSignFields reports which mandatory fields are still empty for signing.
|
||||
// Only the signer is required — it is auto-filled from the duty pilot. The other
|
||||
// document fields (date, A/C hours, location, WO no, manual revisions) are
|
||||
// adjustable free text and optional, so a release can be signed without them.
|
||||
func (e *EASARelease) MissingSignFields() []string {
|
||||
if e == nil {
|
||||
return []string{"easa_signer_id"}
|
||||
}
|
||||
var missing []string
|
||||
if e.EASASignerID == nil || *e.EASASignerID == "" {
|
||||
missing = append(missing, "easa_signer_id")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
83
internal/domain/easa_release/model_test.go
Normal file
83
internal/domain/easa_release/model_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package easarelease
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func str(s string) *string { return &s }
|
||||
|
||||
func TestEASAReleaseIsSigned(t *testing.T) {
|
||||
now := time.Now()
|
||||
if (&EASARelease{}).IsSigned() {
|
||||
t.Fatal("unsigned release reported as signed")
|
||||
}
|
||||
if !(&EASARelease{SignedAt: &now}).IsSigned() {
|
||||
t.Fatal("signed release reported as unsigned")
|
||||
}
|
||||
var nilRel *EASARelease
|
||||
if nilRel.IsSigned() {
|
||||
t.Fatal("nil release reported as signed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEASAReleaseMissingSignFields(t *testing.T) {
|
||||
// Only the auto-filled signer is required to sign; the adjustable document
|
||||
// fields (date, A/C hours, location, WO no, manual revisions) are optional.
|
||||
onlySigner := []string{"easa_signer_id"}
|
||||
now := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
row *EASARelease
|
||||
want []string
|
||||
}{
|
||||
{name: "nil requires signer", row: nil, want: onlySigner},
|
||||
{name: "empty requires signer", row: &EASARelease{}, want: onlySigner},
|
||||
{name: "signer present lists none", row: &EASARelease{EASASignerID: str("019d61db-53c6-7280-87ec-e65f205c6d3b")}, want: nil},
|
||||
{name: "other fields empty but signer set lists none", row: &EASARelease{EASASignerID: str("019d61db-53c6-7280-87ec-e65f205c6d3b"), EASADate: &now}, want: nil},
|
||||
{name: "blank signer counts as missing", row: &EASARelease{EASASignerID: str("")}, want: onlySigner},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.row.MissingSignFields()
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("MissingSignFields() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEASAReleaseIsActiveAndTableName(t *testing.T) {
|
||||
if (EASARelease{}).TableName() != "easa_releases" {
|
||||
t.Fatalf("unexpected table name")
|
||||
}
|
||||
now := time.Now()
|
||||
if (&EASARelease{}).IsActive() {
|
||||
t.Fatal("unsigned release must not be active")
|
||||
}
|
||||
if !(&EASARelease{SignedAt: &now}).IsActive() {
|
||||
t.Fatal("signed release must be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEASAReleaseBeforeCreate(t *testing.T) {
|
||||
e := &EASARelease{}
|
||||
if err := e.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate err: %v", err)
|
||||
}
|
||||
if len(e.ID) == 0 {
|
||||
t.Fatal("BeforeCreate should set ID")
|
||||
}
|
||||
prev := string(e.ID)
|
||||
if err := e.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate err: %v", err)
|
||||
}
|
||||
if string(e.ID) != prev {
|
||||
t.Fatal("BeforeCreate must not overwrite an existing ID")
|
||||
}
|
||||
}
|
||||
16
internal/domain/easa_release/repository.go
Normal file
16
internal/domain/easa_release/repository.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package easarelease
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *EASARelease) error
|
||||
Update(ctx context.Context, row *EASARelease) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*EASARelease, error)
|
||||
GetByFlightID(ctx context.Context, flightID []byte) (*EASARelease, error)
|
||||
GetByComplaintID(ctx context.Context, complaintID []byte) (*EASARelease, error)
|
||||
ListByHelicopter(ctx context.Context, helicopterID []byte) ([]EASARelease, error)
|
||||
GetLatestByHelicopter(ctx context.Context, helicopterID []byte) (*EASARelease, error)
|
||||
GetActiveByHelicopter(ctx context.Context, helicopterID []byte) (*EASARelease, error)
|
||||
VoidByID(ctx context.Context, id []byte, reason string, actor []byte) error
|
||||
}
|
||||
16
internal/domain/easa_release/service.go
Normal file
16
internal/domain/easa_release/service.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package easarelease
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *EASARelease) error
|
||||
Update(ctx context.Context, row *EASARelease) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*EASARelease, error)
|
||||
GetByFlightID(ctx context.Context, flightID []byte) (*EASARelease, error)
|
||||
GetByComplaintID(ctx context.Context, complaintID []byte) (*EASARelease, error)
|
||||
ListByHelicopter(ctx context.Context, helicopterID []byte) ([]EASARelease, error)
|
||||
GetActiveByHelicopter(ctx context.Context, helicopterID []byte) (*EASARelease, error)
|
||||
Sign(ctx context.Context, id []byte, signerID []byte) error
|
||||
Amend(ctx context.Context, currentID []byte, reason string, actor []byte) (newID []byte, err error)
|
||||
}
|
||||
38
internal/domain/facility/model.go
Normal file
38
internal/domain/facility/model.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package facility
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// Facility stores configurable HEMS / Dry Lease (and other) operational equipment rows.
|
||||
type Facility struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Category string `gorm:"type:varchar(64);index;not null;column:category"`
|
||||
Name string `gorm:"type:varchar(150);not null;column:name"`
|
||||
Type string `gorm:"type:varchar(100);not null;column:type"`
|
||||
SortKey *int `gorm:"column:sortkey"`
|
||||
Length string `gorm:"type:varchar(64);column:length"`
|
||||
Weight string `gorm:"type:varchar(64);column:weight"`
|
||||
Electric string `gorm:"type:varchar(255);not null;default:'';column:electric"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
IsActive bool `gorm:"type:tinyint(1);index;not null;default:1;column:is_active"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index"`
|
||||
}
|
||||
|
||||
func (Facility) TableName() string { return "facilities" }
|
||||
|
||||
func (f *Facility) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
internal/domain/facility/repository.go
Normal file
11
internal/domain/facility/repository.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package facility
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, facility *Facility) error
|
||||
Update(ctx context.Context, facility *Facility) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Facility, error)
|
||||
List(ctx context.Context, filter, category, sort string, limit, offset int) ([]Facility, int64, error)
|
||||
}
|
||||
11
internal/domain/facility/service.go
Normal file
11
internal/domain/facility/service.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package facility
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, facility *Facility) error
|
||||
Update(ctx context.Context, facility *Facility) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Facility, error)
|
||||
List(ctx context.Context, filter, category, sort string, limit, offset int) ([]Facility, int64, error)
|
||||
}
|
||||
37
internal/domain/federal_state/model.go
Normal file
37
internal/domain/federal_state/model.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package federal_state
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/land"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FederalState struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
LandID []byte `gorm:"type:binary(16);column:land_id"`
|
||||
Name string `gorm:"type:varchar(128);not null;column:name"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
SortKey *int `gorm:"column:sortkey"`
|
||||
IsActive bool `gorm:"type:tinyint(1);index;not null;default:1;column:is_active"`
|
||||
|
||||
Land *land.Land `gorm:"foreignKey:LandID;references:ID"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (FederalState) TableName() string { return "federal_states" }
|
||||
|
||||
func (f *FederalState) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
internal/domain/federal_state/model_test.go
Normal file
37
internal/domain/federal_state/model_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package federal_state
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFederalStateTableName(t *testing.T) {
|
||||
var m FederalState
|
||||
if got := m.TableName(); got != "federal_states" {
|
||||
t.Fatalf("expected table name federal_states, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateBeforeCreate(t *testing.T) {
|
||||
t.Run("generates id when empty", func(t *testing.T) {
|
||||
m := &FederalState{}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(m.ID) == 0 {
|
||||
t.Fatalf("expected id to be generated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps existing id", func(t *testing.T) {
|
||||
existing := []byte("already-set-id")
|
||||
m := &FederalState{ID: append([]byte(nil), existing...)}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(m.ID) != string(existing) {
|
||||
t.Fatalf("expected existing id to stay unchanged")
|
||||
}
|
||||
})
|
||||
}
|
||||
11
internal/domain/federal_state/repository.go
Normal file
11
internal/domain/federal_state/repository.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package federal_state
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *FederalState) error
|
||||
Update(ctx context.Context, row *FederalState) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FederalState, error)
|
||||
List(ctx context.Context, filter, sort string, limit, offset int, landID []byte, landName string) ([]FederalState, int64, error)
|
||||
}
|
||||
11
internal/domain/federal_state/service.go
Normal file
11
internal/domain/federal_state/service.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package federal_state
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *FederalState) error
|
||||
Update(ctx context.Context, row *FederalState) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FederalState, error)
|
||||
List(ctx context.Context, filter, sort string, limit, offset int, landID []byte, landName string) ([]FederalState, int64, error)
|
||||
}
|
||||
152
internal/domain/file_manager/file_processing_outbox.go
Normal file
152
internal/domain/file_manager/file_processing_outbox.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
FileProcessingOutboxStatusPending = "pending"
|
||||
FileProcessingOutboxStatusProcessing = "processing"
|
||||
FileProcessingOutboxStatusPublished = "published"
|
||||
FileProcessingOutboxStatusDead = "dead"
|
||||
FileProcessingOutboxKind = "file_manager.processing"
|
||||
)
|
||||
|
||||
type FileProcessingOutboundMessage struct {
|
||||
ID string
|
||||
Body []byte
|
||||
Attributes map[string]string
|
||||
MessageGroupID string
|
||||
DeduplicationID string
|
||||
}
|
||||
|
||||
type FileProcessingOutboxMessage struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
MessageID string `gorm:"type:varchar(64);uniqueIndex;not null;column:message_id"`
|
||||
Kind string `gorm:"type:varchar(64);index;not null;column:kind"`
|
||||
JobType string `gorm:"type:varchar(64);index;not null;column:job_type"`
|
||||
Body []byte `gorm:"type:longblob;not null;column:body"`
|
||||
Attributes []byte `gorm:"type:longblob;column:attributes"`
|
||||
MessageGroupID string `gorm:"type:varchar(128);column:message_group_id"`
|
||||
DeduplicationID string `gorm:"type:varchar(128);column:deduplication_id"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;column:status"`
|
||||
Attempts int `gorm:"not null;default:0;column:attempts"`
|
||||
AvailableAt time.Time `gorm:"index;not null;column:available_at"`
|
||||
LockedAt *time.Time `gorm:"index;column:locked_at"`
|
||||
LockedBy string `gorm:"type:varchar(64);column:locked_by"`
|
||||
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||
LastError string `gorm:"type:text;column:last_error"`
|
||||
CorrelationID string `gorm:"type:varchar(64);index;column:correlation_id"`
|
||||
TraceID string `gorm:"type:varchar(64);index;column:trace_id"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (FileProcessingOutboxMessage) TableName() string {
|
||||
return "file_processing_outbox_messages"
|
||||
}
|
||||
|
||||
func (m *FileProcessingOutboxMessage) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if strings.TrimSpace(m.Status) == "" {
|
||||
m.Status = FileProcessingOutboxStatusPending
|
||||
}
|
||||
if m.AvailableAt.IsZero() {
|
||||
m.AvailableAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFileProcessingOutboxMessage(now time.Time, message *FileProcessingOutboundMessage) (*FileProcessingOutboxMessage, error) {
|
||||
if message == nil {
|
||||
return nil, errors.New("outbound message is required")
|
||||
}
|
||||
if strings.TrimSpace(message.ID) == "" {
|
||||
return nil, errors.New("outbound message id is required")
|
||||
}
|
||||
if len(message.Body) == 0 {
|
||||
return nil, errors.New("outbound message body is required")
|
||||
}
|
||||
attributes, err := json.Marshal(message.Attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
kind := strings.TrimSpace(message.Attributes["message_kind"])
|
||||
if kind == "" {
|
||||
kind = FileProcessingOutboxKind
|
||||
}
|
||||
jobType := strings.TrimSpace(message.Attributes["job_type"])
|
||||
if jobType == "" {
|
||||
jobType = "file_processing"
|
||||
}
|
||||
|
||||
return &FileProcessingOutboxMessage{
|
||||
MessageID: strings.TrimSpace(message.ID),
|
||||
Kind: kind,
|
||||
JobType: jobType,
|
||||
Body: append([]byte(nil), message.Body...),
|
||||
Attributes: append([]byte(nil), attributes...),
|
||||
MessageGroupID: strings.TrimSpace(message.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(message.DeduplicationID),
|
||||
Status: FileProcessingOutboxStatusPending,
|
||||
AvailableAt: now.UTC(),
|
||||
CorrelationID: strings.TrimSpace(message.Attributes["correlation_id"]),
|
||||
TraceID: strings.TrimSpace(message.Attributes["trace_id"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *FileProcessingOutboxMessage) OutboundMessage() (*FileProcessingOutboundMessage, error) {
|
||||
if m == nil {
|
||||
return nil, errors.New("file processing outbox message is required")
|
||||
}
|
||||
attributes := map[string]string{}
|
||||
if len(m.Attributes) > 0 {
|
||||
if err := json.Unmarshal(m.Attributes, &attributes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.MessageID) == "" {
|
||||
return nil, errors.New("message id is required")
|
||||
}
|
||||
if len(m.Body) == 0 {
|
||||
return nil, errors.New("message body is required")
|
||||
}
|
||||
return &FileProcessingOutboundMessage{
|
||||
ID: m.MessageID,
|
||||
Body: append([]byte(nil), m.Body...),
|
||||
Attributes: attributes,
|
||||
MessageGroupID: strings.TrimSpace(m.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(m.DeduplicationID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type FileProcessingOutboxWriter interface {
|
||||
CreateFileProcessingOutboxMessage(ctx context.Context, message *FileProcessingOutboxMessage) error
|
||||
}
|
||||
|
||||
type FileProcessingOutboxRepository interface {
|
||||
FileProcessingOutboxWriter
|
||||
ClaimPendingFileProcessingOutboxMessages(ctx context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]FileProcessingOutboxMessage, error)
|
||||
MarkFileProcessingOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error
|
||||
MarkFileProcessingOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error
|
||||
MarkFileProcessingOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error
|
||||
CountPendingFileProcessingOutboxMessages(ctx context.Context, now time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type FileTransactionalRepository interface {
|
||||
WithTransaction(ctx context.Context, fn func(repo FileRepository, outbox FileProcessingOutboxWriter) error) error
|
||||
}
|
||||
47
internal/domain/file_manager/model_attachment.go
Normal file
47
internal/domain/file_manager/model_attachment.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Attachment struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FileID []byte `gorm:"type:binary(16);not null;column:file_id;index:idx_attachments_file_id;uniqueIndex:uq_attachments_ref_file,priority:3"`
|
||||
RefType string `gorm:"type:varchar(64);not null;column:ref_type;index:idx_attachments_ref_sort,priority:1;index:idx_attachments_ref_category,priority:1;uniqueIndex:uq_attachments_ref_file,priority:1"`
|
||||
RefID string `gorm:"type:varchar(191);not null;column:ref_id;index:idx_attachments_ref_sort,priority:2;index:idx_attachments_ref_category,priority:2;uniqueIndex:uq_attachments_ref_file,priority:2"`
|
||||
Category *string `gorm:"type:varchar(64);column:category;index:idx_attachments_ref_category,priority:3"`
|
||||
SortOrder int `gorm:"type:int;not null;default:0;column:sort_order;index:idx_attachments_ref_sort,priority:3"`
|
||||
IsPrimary bool `gorm:"type:boolean;not null;default:false;column:is_primary"`
|
||||
AttachedBy []byte `gorm:"type:binary(16);column:attached_by"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;index:idx_attachments_ref_sort,priority:4"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
File *File `gorm:"foreignKey:FileID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (Attachment) TableName() string { return "attachments" }
|
||||
|
||||
func (a *Attachment) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if trimmed := strings.TrimSpace(a.RefType); trimmed != "" {
|
||||
a.RefType = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(a.RefID); trimmed != "" {
|
||||
a.RefID = trimmed
|
||||
}
|
||||
if a.Category != nil {
|
||||
value := strings.TrimSpace(*a.Category)
|
||||
if value == "" {
|
||||
a.Category = nil
|
||||
} else {
|
||||
a.Category = &value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
149
internal/domain/file_manager/model_file.go
Normal file
149
internal/domain/file_manager/model_file.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
FileStatusUploaded = "uploaded"
|
||||
FileStatusProcessing = "processing"
|
||||
FileStatusValidated = "validated"
|
||||
FileStatusFailed = "failed"
|
||||
FileStatusReady = "ready"
|
||||
FileStatusTrashed = "trashed"
|
||||
|
||||
FileLifecycleStatusTemp = "temp"
|
||||
FileLifecycleStatusDraft = "draft"
|
||||
FileLifecycleStatusActive = "active"
|
||||
FileLifecycleStatusFinal = "final"
|
||||
FileLifecycleStatusOrphanPending = "orphan_pending"
|
||||
FileLifecycleStatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FolderID []byte `gorm:"type:binary(16);not null;column:folder_id;index:idx_file_files_folder_active,priority:1;index:uq_file_files_folder_name_slot,priority:1"`
|
||||
Name string `gorm:"type:varchar(255);not null;column:name"`
|
||||
NameNormalized string `gorm:"type:varchar(255);not null;column:name_normalized;index:uq_file_files_folder_name_slot,priority:2"`
|
||||
Extension string `gorm:"type:varchar(32);column:extension"`
|
||||
SizeBytes int64 `gorm:"type:bigint unsigned;not null;default:0;column:size_bytes"`
|
||||
MimeType string `gorm:"type:varchar(255);not null;column:mime_type"`
|
||||
Bucket string `gorm:"type:varchar(128);not null;column:bucket"`
|
||||
ObjectKey string `gorm:"type:varchar(512);not null;column:object_key;uniqueIndex:uq_file_files_object_key"`
|
||||
ObjectVersion string `gorm:"type:varchar(128);column:object_version"`
|
||||
ETag string `gorm:"type:varchar(128);column:etag"`
|
||||
ThumbnailMimeType *string `gorm:"type:varchar(255);column:thumbnail_mime_type"`
|
||||
ThumbnailSizeBytes int64 `gorm:"type:bigint unsigned;column:thumbnail_size_bytes"`
|
||||
ThumbnailBucket *string `gorm:"type:varchar(128);column:thumbnail_bucket"`
|
||||
ThumbnailObjectKey *string `gorm:"type:varchar(512);column:thumbnail_object_key;uniqueIndex:uq_file_files_thumbnail_object_key"`
|
||||
ThumbnailObjectVersion *string `gorm:"type:varchar(128);column:thumbnail_object_version"`
|
||||
ThumbnailETag *string `gorm:"type:varchar(128);column:thumbnail_etag"`
|
||||
PreviewMimeType *string `gorm:"type:varchar(255);column:preview_mime_type"`
|
||||
PreviewSizeBytes int64 `gorm:"type:bigint unsigned;column:preview_size_bytes"`
|
||||
PreviewBucket *string `gorm:"type:varchar(128);column:preview_bucket"`
|
||||
PreviewObjectKey *string `gorm:"type:varchar(512);column:preview_object_key;uniqueIndex:uq_file_files_preview_object_key"`
|
||||
PreviewObjectVersion *string `gorm:"type:varchar(128);column:preview_object_version"`
|
||||
PreviewETag *string `gorm:"type:varchar(128);column:preview_etag"`
|
||||
ChecksumSHA256 string `gorm:"type:char(64);column:checksum_sha256"`
|
||||
IsTemplate bool `gorm:"column:is_template;not null;default:false"`
|
||||
TemplateUUID []byte `gorm:"type:binary(16);column:template_uuid;index:idx_file_files_template_takeover,priority:1"`
|
||||
TakeoverID []byte `gorm:"type:binary(16);column:takeover_id;index:idx_file_files_template_takeover,priority:2"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:'uploaded';column:status;index:idx_file_files_status_updated,priority:1"`
|
||||
ProcessingStartedAt *time.Time `gorm:"column:processing_started_at"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at"`
|
||||
ValidatedAt *time.Time `gorm:"column:validated_at"`
|
||||
FailedAt *time.Time `gorm:"column:failed_at"`
|
||||
FailureReason string `gorm:"type:text;column:failure_reason"`
|
||||
UploadError string `gorm:"type:text;column:upload_error"`
|
||||
NameSlot string `gorm:"type:char(36);not null;default:'live';column:name_slot;index:uq_file_files_folder_name_slot,priority:3"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;index:idx_file_files_status_updated,priority:2"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index:idx_file_files_folder_active,priority:2"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
TrashedAt *time.Time `gorm:"column:trashed_at"`
|
||||
PurgeAt *time.Time `gorm:"column:purge_at;index:idx_file_files_purge_at"`
|
||||
LifecycleStatus string `gorm:"type:varchar(32);not null;default:'temp';column:lifecycle_status;index:idx_file_files_lifecycle_status_orphaned,priority:1"`
|
||||
AttachedAt *time.Time `gorm:"column:attached_at"`
|
||||
OrphanedAt *time.Time `gorm:"column:orphaned_at;index:idx_file_files_lifecycle_status_orphaned,priority:2"`
|
||||
LifecycleDeletedAt *time.Time `gorm:"column:lifecycle_deleted_at"`
|
||||
WOPILockID string `gorm:"type:varchar(128);column:wopi_lock_id"`
|
||||
WOPILockJTI string `gorm:"type:varchar(128);column:wopi_lock_jti"`
|
||||
WOPILockedAt *time.Time `gorm:"column:wopi_locked_at"`
|
||||
WOPILockExpiresAt *time.Time `gorm:"column:wopi_lock_expires_at"`
|
||||
|
||||
Folder *Folder `gorm:"foreignKey:FolderID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
}
|
||||
|
||||
func (File) TableName() string { return "file_files" }
|
||||
|
||||
func (f *File) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeFileStatus(status string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(status))
|
||||
switch s {
|
||||
case "pending":
|
||||
return FileStatusUploaded
|
||||
case "uploading":
|
||||
return FileStatusProcessing
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func IsKnownFileStatus(status string) bool {
|
||||
switch NormalizeFileStatus(status) {
|
||||
case FileStatusUploaded, FileStatusProcessing, FileStatusValidated, FileStatusFailed, FileStatusReady, FileStatusTrashed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func CanTransitionFileStatus(fromStatus, toStatus string) bool {
|
||||
from := NormalizeFileStatus(fromStatus)
|
||||
to := NormalizeFileStatus(toStatus)
|
||||
|
||||
switch from {
|
||||
case FileStatusUploaded:
|
||||
return to == FileStatusProcessing || to == FileStatusFailed
|
||||
case FileStatusProcessing:
|
||||
return to == FileStatusValidated || to == FileStatusFailed
|
||||
case FileStatusValidated:
|
||||
return to == FileStatusReady || to == FileStatusFailed
|
||||
case FileStatusReady:
|
||||
return to == FileStatusTrashed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeFileLifecycleStatus(status string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(status))
|
||||
switch s {
|
||||
case "":
|
||||
return FileLifecycleStatusTemp
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func IsKnownFileLifecycleStatus(status string) bool {
|
||||
switch NormalizeFileLifecycleStatus(status) {
|
||||
case FileLifecycleStatusTemp, FileLifecycleStatusDraft, FileLifecycleStatusActive, FileLifecycleStatusFinal, FileLifecycleStatusOrphanPending, FileLifecycleStatusDeleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
123
internal/domain/file_manager/model_file_test.go
Normal file
123
internal/domain/file_manager/model_file_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFileTableName(t *testing.T) {
|
||||
var m File
|
||||
if got := m.TableName(); got != "file_files" {
|
||||
t.Fatalf("table name mismatch: got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBeforeCreate(t *testing.T) {
|
||||
t.Run("assigns uuid when missing", func(t *testing.T) {
|
||||
m := &File{}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
if len(m.ID) != 16 {
|
||||
t.Fatalf("expected 16-byte id, got %d", len(m.ID))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps existing id", func(t *testing.T) {
|
||||
id := make([]byte, 16)
|
||||
for i := range id {
|
||||
id[i] = byte(i + 1)
|
||||
}
|
||||
m := &File{ID: append([]byte(nil), id...)}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
for i := range id {
|
||||
if m.ID[i] != id[i] {
|
||||
t.Fatalf("id changed at %d: got %d want %d", i, m.ID[i], id[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeFileStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"uploaded": FileStatusUploaded,
|
||||
"processing": FileStatusProcessing,
|
||||
"validated": FileStatusValidated,
|
||||
"failed": FileStatusFailed,
|
||||
"ready": FileStatusReady,
|
||||
"trashed": FileStatusTrashed,
|
||||
" pending ": FileStatusUploaded,
|
||||
"UPLOADING": FileStatusProcessing,
|
||||
"": "",
|
||||
}
|
||||
|
||||
for in, want := range cases {
|
||||
if got := NormalizeFileStatus(in); got != want {
|
||||
t.Fatalf("NormalizeFileStatus(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanTransitionFileStatus(t *testing.T) {
|
||||
valid := [][2]string{
|
||||
{FileStatusUploaded, FileStatusProcessing},
|
||||
{FileStatusUploaded, FileStatusFailed},
|
||||
{FileStatusProcessing, FileStatusValidated},
|
||||
{FileStatusProcessing, FileStatusFailed},
|
||||
{FileStatusValidated, FileStatusReady},
|
||||
{FileStatusValidated, FileStatusFailed},
|
||||
{FileStatusReady, FileStatusTrashed},
|
||||
}
|
||||
for i := range valid {
|
||||
if !CanTransitionFileStatus(valid[i][0], valid[i][1]) {
|
||||
t.Fatalf("expected valid transition %s -> %s", valid[i][0], valid[i][1])
|
||||
}
|
||||
}
|
||||
|
||||
invalid := [][2]string{
|
||||
{FileStatusUploaded, FileStatusReady},
|
||||
{FileStatusFailed, FileStatusReady},
|
||||
{FileStatusTrashed, FileStatusProcessing},
|
||||
{FileStatusReady, FileStatusValidated},
|
||||
}
|
||||
for i := range invalid {
|
||||
if CanTransitionFileStatus(invalid[i][0], invalid[i][1]) {
|
||||
t.Fatalf("expected invalid transition %s -> %s", invalid[i][0], invalid[i][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFileLifecycleStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": FileLifecycleStatusTemp,
|
||||
" temp ": FileLifecycleStatusTemp,
|
||||
"ACTIVE": FileLifecycleStatusActive,
|
||||
"orphan_pending": FileLifecycleStatusOrphanPending,
|
||||
"deleted": FileLifecycleStatusDeleted,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizeFileLifecycleStatus(in); got != want {
|
||||
t.Fatalf("NormalizeFileLifecycleStatus(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownFileLifecycleStatus(t *testing.T) {
|
||||
known := []string{
|
||||
FileLifecycleStatusTemp,
|
||||
FileLifecycleStatusActive,
|
||||
FileLifecycleStatusOrphanPending,
|
||||
FileLifecycleStatusDeleted,
|
||||
}
|
||||
for i := range known {
|
||||
if !IsKnownFileLifecycleStatus(known[i]) {
|
||||
t.Fatalf("expected known lifecycle status %q", known[i])
|
||||
}
|
||||
}
|
||||
if IsKnownFileLifecycleStatus("unknown") {
|
||||
t.Fatalf("expected unknown lifecycle status to be false")
|
||||
}
|
||||
}
|
||||
37
internal/domain/file_manager/model_folder.go
Normal file
37
internal/domain/file_manager/model_folder.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Folder struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
ParentID []byte `gorm:"type:binary(16);column:parent_id;index:idx_file_folders_parent_active,priority:1;index:uq_file_folders_parent_name_slot,priority:1"`
|
||||
Name string `gorm:"type:varchar(128);not null;column:name"`
|
||||
NameNormalized string `gorm:"type:varchar(128);not null;column:name_normalized;index:uq_file_folders_parent_name_slot,priority:2"`
|
||||
Depth int `gorm:"type:smallint unsigned;not null;default:0;column:depth"`
|
||||
PathCache string `gorm:"type:varchar(2048);column:path_cache"`
|
||||
NameSlot string `gorm:"type:char(36);not null;default:'live';column:name_slot;index:uq_file_folders_parent_name_slot,priority:3"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index:idx_file_folders_parent_active,priority:2;index:idx_file_folders_deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
PurgeAt *time.Time `gorm:"column:purge_at;index:idx_file_folders_purge_at"`
|
||||
|
||||
Parent *Folder `gorm:"foreignKey:ParentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
}
|
||||
|
||||
func (Folder) TableName() string { return "file_folders" }
|
||||
|
||||
func (f *Folder) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
42
internal/domain/file_manager/model_folder_test.go
Normal file
42
internal/domain/file_manager/model_folder_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFolderTableName(t *testing.T) {
|
||||
var m Folder
|
||||
if got := m.TableName(); got != "file_folders" {
|
||||
t.Fatalf("table name mismatch: got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderBeforeCreate(t *testing.T) {
|
||||
t.Run("assigns uuid when missing", func(t *testing.T) {
|
||||
m := &Folder{}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
if len(m.ID) != 16 {
|
||||
t.Fatalf("expected 16-byte id, got %d", len(m.ID))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps existing id", func(t *testing.T) {
|
||||
id := make([]byte, 16)
|
||||
for i := range id {
|
||||
id[i] = byte(i + 1)
|
||||
}
|
||||
m := &Folder{ID: append([]byte(nil), id...)}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
for i := range id {
|
||||
if m.ID[i] != id[i] {
|
||||
t.Fatalf("id changed at %d: got %d want %d", i, m.ID[i], id[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
77
internal/domain/file_manager/realtime_event.go
Normal file
77
internal/domain/file_manager/realtime_event.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FileStatusRealtimeEventInput struct {
|
||||
FileID []byte
|
||||
UserID []byte
|
||||
Status string
|
||||
Name string
|
||||
FailureReason string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type FileStatusRealtimeEventWriter interface {
|
||||
CreateFileStatusRealtimeEvent(ctx context.Context, input FileStatusRealtimeEventInput) error
|
||||
}
|
||||
|
||||
type FileStatusRealtimeEvent struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FileID []byte `gorm:"type:binary(16);index;not null;column:file_id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;column:status"`
|
||||
Name string `gorm:"type:varchar(255);not null;column:name"`
|
||||
FailureReason string `gorm:"type:text;column:failure_reason"`
|
||||
OccurredAt time.Time `gorm:"index;not null;column:occurred_at"`
|
||||
DeliveredAt *time.Time `gorm:"index;column:delivered_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (FileStatusRealtimeEvent) TableName() string {
|
||||
return "file_status_realtime_events"
|
||||
}
|
||||
|
||||
func (e *FileStatusRealtimeEvent) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(e.ID) == 0 {
|
||||
e.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
e.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFileStatusRealtimeEvent(input FileStatusRealtimeEventInput) (*FileStatusRealtimeEvent, error) {
|
||||
if len(input.FileID) != 16 {
|
||||
return nil, errors.New("file id must be uuid bytes")
|
||||
}
|
||||
if len(input.UserID) != 16 {
|
||||
return nil, errors.New("user id must be uuid bytes")
|
||||
}
|
||||
status := NormalizeFileStatus(input.Status)
|
||||
if !IsKnownFileStatus(status) {
|
||||
return nil, errors.New("invalid status")
|
||||
}
|
||||
occurredAt := input.OccurredAt.UTC()
|
||||
if input.OccurredAt.IsZero() {
|
||||
occurredAt = time.Now().UTC()
|
||||
}
|
||||
return &FileStatusRealtimeEvent{
|
||||
FileID: append([]byte(nil), input.FileID...),
|
||||
UserID: append([]byte(nil), input.UserID...),
|
||||
Status: status,
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
FailureReason: strings.TrimSpace(input.FailureReason),
|
||||
OccurredAt: occurredAt,
|
||||
}, nil
|
||||
}
|
||||
131
internal/domain/file_manager/repository.go
Normal file
131
internal/domain/file_manager/repository.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FolderRepository interface {
|
||||
Create(ctx context.Context, row *Folder) error
|
||||
Update(ctx context.Context, row *Folder) error
|
||||
MoveSubtree(ctx context.Context, id []byte, targetParentID []byte, targetName string, targetNameNormalized string, updatedBy []byte) (*Folder, error)
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
Restore(ctx context.Context, id []byte, restoredBy []byte) error
|
||||
Purge(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetByIDAny(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*Folder, error)
|
||||
ListByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
ListTrash(ctx context.Context, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
ListTrashByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
}
|
||||
|
||||
type FileFinalizeParams struct {
|
||||
ID []byte
|
||||
ExpectedFrom string
|
||||
FolderID []byte
|
||||
Name string
|
||||
NameNormalized string
|
||||
Extension string
|
||||
UpdatedBy []byte
|
||||
}
|
||||
|
||||
type FileStatusTransitionParams struct {
|
||||
ID []byte
|
||||
ExpectedFrom string
|
||||
To string
|
||||
UpdatedBy []byte
|
||||
ProcessingStartedAt *time.Time
|
||||
ProcessedAt *time.Time
|
||||
ValidatedAt *time.Time
|
||||
FailedAt *time.Time
|
||||
FailureReason *string
|
||||
}
|
||||
|
||||
type FileRepository interface {
|
||||
Create(ctx context.Context, row *File) error
|
||||
Update(ctx context.Context, row *File) error
|
||||
TransitionStatus(ctx context.Context, params FileStatusTransitionParams) error
|
||||
Finalize(ctx context.Context, params FileFinalizeParams) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
Restore(ctx context.Context, id []byte, restoredBy []byte) error
|
||||
Purge(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*File, error)
|
||||
GetByIDAny(ctx context.Context, id []byte) (*File, error)
|
||||
GetByObjectKey(ctx context.Context, objectKey string) (*File, error)
|
||||
GetByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*File, error)
|
||||
GetByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*File, error)
|
||||
ListEditedTemplatesByTakeoverID(ctx context.Context, takeoverID []byte) ([]File, error)
|
||||
ListByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]File, int64, error)
|
||||
ListTrashByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
}
|
||||
|
||||
type FileLifecycleTransitionParams struct {
|
||||
ID []byte
|
||||
Status string
|
||||
UpdatedBy []byte
|
||||
AttachedAt *time.Time
|
||||
OrphanedAt *time.Time
|
||||
LifecycleDelete *time.Time
|
||||
ClearOrphanedAt bool
|
||||
}
|
||||
|
||||
type FileLifecycleListFilter struct {
|
||||
Status string
|
||||
OrphanedBeforeEq *time.Time
|
||||
CreatedBeforeEq *time.Time
|
||||
ExcludeInTrash bool
|
||||
ExcludeLifecycleDeleted bool
|
||||
Limit int
|
||||
}
|
||||
|
||||
type FileLifecycleRepository interface {
|
||||
UpdateLifecycle(ctx context.Context, params FileLifecycleTransitionParams) error
|
||||
IsFileReferenced(ctx context.Context, fileID []byte) (bool, error)
|
||||
ListFilesByLifecycle(ctx context.Context, filter FileLifecycleListFilter) ([]File, error)
|
||||
}
|
||||
|
||||
type FileUploadIntentCompleteParams struct {
|
||||
ID []byte
|
||||
UpdatedBy []byte
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type FileUploadIntentExpireParams struct {
|
||||
ID []byte
|
||||
UpdatedBy []byte
|
||||
ExpiredAt time.Time
|
||||
}
|
||||
|
||||
type FileUploadIntentSizeUpdateParams struct {
|
||||
ID []byte
|
||||
SizeBytes int64
|
||||
UpdatedBy []byte
|
||||
}
|
||||
|
||||
type FileUploadIntentRepository interface {
|
||||
CreateUploadIntent(ctx context.Context, row *FileUploadIntent) error
|
||||
GetUploadIntentByID(ctx context.Context, id []byte) (*FileUploadIntent, error)
|
||||
GetUploadIntentByIDForUpdate(ctx context.Context, id []byte) (*FileUploadIntent, error)
|
||||
MarkUploadIntentCompleted(ctx context.Context, params FileUploadIntentCompleteParams) error
|
||||
MarkUploadIntentExpired(ctx context.Context, params FileUploadIntentExpireParams) error
|
||||
}
|
||||
|
||||
type AttachmentListFilter struct {
|
||||
RefType string
|
||||
RefID string
|
||||
Category *string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type AttachmentRepository interface {
|
||||
Create(ctx context.Context, row *Attachment) error
|
||||
Delete(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Attachment, error)
|
||||
GetByRefAndFile(ctx context.Context, refType, refID string, fileID []byte) (*Attachment, error)
|
||||
ListByReference(ctx context.Context, filter AttachmentListFilter) ([]Attachment, int64, error)
|
||||
}
|
||||
242
internal/domain/file_manager/service.go
Normal file
242
internal/domain/file_manager/service.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SystemRootFolderName = "__system_root_files__"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateName = errors.New("duplicate name in target folder")
|
||||
ErrInvalidMove = errors.New("invalid folder move")
|
||||
ErrFolderNotFound = errors.New("folder not found")
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
ErrFileAlreadyFinalized = errors.New("file already finalized")
|
||||
ErrFileNotEligibleForFinalize = errors.New("file is not eligible for finalize")
|
||||
ErrInvalidFileStatusTransition = errors.New("invalid file status transition")
|
||||
ErrFileStatusConflict = errors.New("file status conflict")
|
||||
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||
ErrFolderNotEmpty = errors.New("folder is not empty")
|
||||
ErrFolderNotInTrash = errors.New("folder is not in trash")
|
||||
ErrFileNotInTrash = errors.New("file is not in trash")
|
||||
ErrInvalidName = errors.New("invalid name")
|
||||
ErrReservedName = errors.New("reserved name")
|
||||
ErrFileTooLarge = errors.New("file too large")
|
||||
ErrFileMimeTypeNotAllowed = errors.New("file mime type is not allowed")
|
||||
ErrFileExtensionNotAllowed = errors.New("file extension is not allowed")
|
||||
ErrEmptyFileNotAllowed = errors.New("empty file is not allowed")
|
||||
ErrFileNameTooLong = errors.New("file name exceeds maximum length")
|
||||
ErrFileProcessingQueueDisabled = errors.New("file processing queue is not configured")
|
||||
ErrUploadIntentRepositoryDisabled = errors.New("file upload intent repository is not configured")
|
||||
ErrUploadIntentNotFound = errors.New("upload intent not found")
|
||||
ErrUploadIntentExpired = errors.New("upload intent is expired")
|
||||
ErrUploadIntentAlreadyComplete = errors.New("upload intent already completed")
|
||||
ErrUploadIntentStatusConflict = errors.New("upload intent status conflict")
|
||||
ErrUploadObjectNotFound = errors.New("uploaded object not found in storage")
|
||||
ErrUploadObjectMismatch = errors.New("uploaded object does not match upload intent")
|
||||
ErrAttachmentRepositoryDisabled = errors.New("attachment repository is not configured")
|
||||
ErrAttachmentNotFound = errors.New("attachment not found")
|
||||
ErrAttachmentAlreadyExists = errors.New("attachment already exists for this reference")
|
||||
ErrAttachmentRefRequired = errors.New("attachment ref_type and ref_id are required")
|
||||
ErrFileNotReadyForAttachment = errors.New("file is not ready for attachment")
|
||||
)
|
||||
|
||||
type FileUploadPolicy struct {
|
||||
MaxFileSizeBytes int64
|
||||
AllowedMimeTypes []string
|
||||
AllowedExtensions []string
|
||||
AllowEmptyFile bool
|
||||
MaxFilenameLength int
|
||||
}
|
||||
|
||||
type FileProcessingJob struct {
|
||||
JobID string
|
||||
FileUUID string
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
UploadedBy string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
TraceID string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type FileProcessingQueue interface {
|
||||
Enqueue(ctx context.Context, job FileProcessingJob) error
|
||||
}
|
||||
|
||||
type CreateFolderParams struct {
|
||||
Name string
|
||||
ParentID []byte
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type MoveFolderParams struct {
|
||||
Name *string
|
||||
ParentID []byte
|
||||
ParentProvided bool
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type UploadFileParams struct {
|
||||
Name string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
Body io.Reader
|
||||
ActorID []byte
|
||||
IsTemplate bool
|
||||
}
|
||||
|
||||
type RequestFileUploadIntentParams struct {
|
||||
Name string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type FileUploadIntentGrant struct {
|
||||
UploadIntentID []byte
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
Name string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
Method string
|
||||
UploadURL string
|
||||
Headers map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type CompleteFileUploadIntentParams struct {
|
||||
UploadIntentID []byte
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type CancelFileUploadIntentParams struct {
|
||||
UploadIntentID []byte
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type CreateFileFromUploadParams struct {
|
||||
UploadFileID []byte
|
||||
FolderID []byte
|
||||
FolderProvided bool
|
||||
Name *string
|
||||
ActorID []byte
|
||||
IsTemplate bool
|
||||
}
|
||||
|
||||
type CreateFileFromUploadIntentParams struct {
|
||||
UploadIntentID []byte
|
||||
FolderID []byte
|
||||
FolderProvided bool
|
||||
Name string
|
||||
ActorID []byte
|
||||
IsTemplate bool
|
||||
}
|
||||
|
||||
type BulkFileNodeResult struct {
|
||||
Index int
|
||||
File *File
|
||||
Err error
|
||||
}
|
||||
|
||||
type MoveFileParams struct {
|
||||
Name *string
|
||||
FolderID []byte
|
||||
FolderProvided bool
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type CreateAttachmentParams struct {
|
||||
FileID []byte
|
||||
RefType string
|
||||
RefID string
|
||||
Category *string
|
||||
SortOrder int
|
||||
IsPrimary bool
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type ListAttachmentsParams struct {
|
||||
RefType string
|
||||
RefID string
|
||||
Category *string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type FileCleanupStats struct {
|
||||
Scanned int
|
||||
Deleted int
|
||||
Skipped int
|
||||
Failed int
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// LifecycleManager is an optional extension implemented by concrete services
|
||||
// to expose file lifecycle transitions without widening the base Service
|
||||
// interface used by existing handlers/tests.
|
||||
type LifecycleManager interface {
|
||||
MarkFileActive(ctx context.Context, fileID []byte, actorID []byte) error
|
||||
ReconcileFileLifecycle(ctx context.Context, fileID []byte, actorID []byte) error
|
||||
CleanupOrphanPendingFiles(ctx context.Context, now time.Time, gracePeriod time.Duration, limit int, dryRun bool) (FileCleanupStats, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
SetObjectStorage(storage ObjectStorage)
|
||||
SetUploadPolicy(policy FileUploadPolicy)
|
||||
SetFileProcessingQueue(fileQueue FileProcessingQueue)
|
||||
SetFileUploadIntentRepository(repo FileUploadIntentRepository)
|
||||
RequestFileUploadIntent(ctx context.Context, params RequestFileUploadIntentParams) (*FileUploadIntentGrant, error)
|
||||
CompleteFileUploadIntent(ctx context.Context, params CompleteFileUploadIntentParams) (*File, error)
|
||||
CancelFileUploadIntent(ctx context.Context, params CancelFileUploadIntentParams) error
|
||||
CreateFileNodeFromUploadIntent(ctx context.Context, params CreateFileFromUploadIntentParams) (*File, error)
|
||||
GetOrCreateSystemRootFolder(ctx context.Context) (*Folder, error)
|
||||
CreateFolderNode(ctx context.Context, params CreateFolderParams) (*Folder, error)
|
||||
MoveFolderNode(ctx context.Context, id []byte, params MoveFolderParams) (*Folder, error)
|
||||
UploadFileNode(ctx context.Context, params UploadFileParams) (*File, error)
|
||||
CreateFileNodeFromUpload(ctx context.Context, params CreateFileFromUploadParams) (*File, error)
|
||||
UploadFileNodesBulk(ctx context.Context, params []UploadFileParams) []BulkFileNodeResult
|
||||
CreateFileNodesFromUploadBulk(ctx context.Context, params []CreateFileFromUploadParams) []BulkFileNodeResult
|
||||
MoveFileNode(ctx context.Context, id []byte, params MoveFileParams) (*File, error)
|
||||
PurgeFolderNode(ctx context.Context, id []byte) error
|
||||
PurgeFileNode(ctx context.Context, id []byte) error
|
||||
ListNodesByParent(ctx context.Context, parentID []byte) ([]Folder, []File, error)
|
||||
SearchNodes(ctx context.Context, query string) ([]Folder, []File, error)
|
||||
ListTrashNodes(ctx context.Context) ([]Folder, []File, error)
|
||||
|
||||
CreateFolder(ctx context.Context, row *Folder) error
|
||||
UpdateFolder(ctx context.Context, row *Folder) error
|
||||
DeleteFolder(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
RestoreFolder(ctx context.Context, id []byte, targetParentID []byte, targetProvided bool, restoredBy []byte) error
|
||||
PurgeFolder(ctx context.Context, id []byte) error
|
||||
GetFolderByID(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetFolderByIDAny(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetFolderByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*Folder, error)
|
||||
ListFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
ListTrashFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
|
||||
CreateFile(ctx context.Context, row *File) error
|
||||
UpdateFile(ctx context.Context, row *File) error
|
||||
DeleteFile(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
RestoreFile(ctx context.Context, id []byte, targetFolderID []byte, targetProvided bool, restoredBy []byte) error
|
||||
PurgeFile(ctx context.Context, id []byte) error
|
||||
GetFileByID(ctx context.Context, id []byte) (*File, error)
|
||||
GetFileByIDAny(ctx context.Context, id []byte) (*File, error)
|
||||
GetFileByObjectKey(ctx context.Context, objectKey string) (*File, error)
|
||||
GetFileByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*File, error)
|
||||
ListFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
ListTrashFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
CreateAttachment(ctx context.Context, params CreateAttachmentParams) (*Attachment, error)
|
||||
GetAttachmentByID(ctx context.Context, id []byte) (*Attachment, error)
|
||||
ListAttachmentsByReference(ctx context.Context, params ListAttachmentsParams) ([]Attachment, int64, error)
|
||||
DeleteAttachment(ctx context.Context, id []byte) error
|
||||
ListEditedWBFilesByTakeoverID(ctx context.Context, takeoverID []byte) ([]File, error)
|
||||
}
|
||||
56
internal/domain/file_manager/storage.go
Normal file
56
internal/domain/file_manager/storage.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PutObjectInput struct {
|
||||
ObjectKey string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
Body io.Reader
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type PresignPutObjectInput struct {
|
||||
ObjectKey string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
Metadata map[string]string
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type PresignedPutObject struct {
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type ObjectMetadata struct {
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
ETag string
|
||||
VersionID string
|
||||
SizeBytes int64
|
||||
LastModified *time.Time
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type ObjectStorage interface {
|
||||
EnsureBucket(ctx context.Context) error
|
||||
PutObject(ctx context.Context, input PutObjectInput) (ObjectMetadata, error)
|
||||
DeleteObject(ctx context.Context, objectKey string) error
|
||||
HeadObject(ctx context.Context, objectKey string) (ObjectMetadata, error)
|
||||
PresignPutObject(ctx context.Context, input PresignPutObjectInput) (PresignedPutObject, error)
|
||||
PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error)
|
||||
}
|
||||
|
||||
type ObjectTaggingStorage interface {
|
||||
PutObjectTagging(ctx context.Context, objectKey string, tags map[string]string) error
|
||||
DeleteObjectTaggingKeys(ctx context.Context, objectKey string, keys []string) error
|
||||
}
|
||||
52
internal/domain/file_manager/upload_intent.go
Normal file
52
internal/domain/file_manager/upload_intent.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
FileUploadIntentStatusPending = "pending"
|
||||
FileUploadIntentStatusCompleted = "completed"
|
||||
FileUploadIntentStatusExpired = "expired"
|
||||
)
|
||||
|
||||
type FileUploadIntent struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Name string `gorm:"type:varchar(255);not null;column:name"`
|
||||
NameNormalized string `gorm:"type:varchar(255);not null;column:name_normalized"`
|
||||
Extension string `gorm:"type:varchar(32);column:extension"`
|
||||
SizeBytes int64 `gorm:"type:bigint unsigned;not null;default:0;column:size_bytes"`
|
||||
MimeType string `gorm:"type:varchar(255);not null;column:mime_type"`
|
||||
Bucket string `gorm:"type:varchar(128);not null;column:bucket"`
|
||||
ObjectKey string `gorm:"type:varchar(512);not null;uniqueIndex:uq_file_upload_intents_object_key"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;default:'pending';column:status"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (FileUploadIntent) TableName() string {
|
||||
return "file_upload_intents"
|
||||
}
|
||||
|
||||
func (m *FileUploadIntent) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if strings.TrimSpace(m.Status) == "" {
|
||||
m.Status = FileUploadIntentStatusPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeFileUploadIntentStatus(status string) string {
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
55
internal/domain/fleet_history/model.go
Normal file
55
internal/domain/fleet_history/model.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package fleethistory
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// Entity types a history event can refer to.
|
||||
const (
|
||||
EntityComplaint = "complaint"
|
||||
EntityEASARelease = "easa_release"
|
||||
EntityMCF = "mcf"
|
||||
EntityHelicopter = "helicopter"
|
||||
)
|
||||
|
||||
// Action kinds recorded in the fleet history timeline.
|
||||
const (
|
||||
ActionComplaintFiled = "complaint_filed"
|
||||
ActionMELClassified = "mel_classified"
|
||||
ActionNSRDone = "nsr_done"
|
||||
ActionNSRCancelled = "nsr_cancelled"
|
||||
ActionActionTaken = "action_taken"
|
||||
ActionMCFResult = "mcf_result"
|
||||
ActionEASACreated = "easa_created"
|
||||
ActionEASASigned = "easa_signed"
|
||||
ActionAOGSet = "aog_set"
|
||||
ActionAOGCleared = "aog_cleared"
|
||||
)
|
||||
|
||||
// FleetHistory is an append-only event in a helicopter's timeline. It records who
|
||||
// did what and when across the complaint / MEL / NSR / action / MCF / EASA / AOG
|
||||
// flows, forming the per-aircraft history (servicing events live in their own
|
||||
// service-log table and are merged at read time).
|
||||
type FleetHistory struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
EntityType string `gorm:"type:varchar(32);not null;column:entity_type"`
|
||||
EntityID []byte `gorm:"type:binary(16);column:entity_id"`
|
||||
Action string `gorm:"type:varchar(48);not null;column:action"`
|
||||
Message string `gorm:"type:text;column:message"`
|
||||
Actor []byte `gorm:"type:binary(16);column:actor"`
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);index;column:created_at"`
|
||||
}
|
||||
|
||||
func (FleetHistory) TableName() string { return "fleet_histories" }
|
||||
|
||||
func (f *FleetHistory) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
internal/domain/fleet_history/repository.go
Normal file
11
internal/domain/fleet_history/repository.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package fleethistory
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *FleetHistory) error
|
||||
ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]FleetHistory, int64, error)
|
||||
// HelicopterIDByInspection resolves the helicopter linked to a flight inspection
|
||||
// via its reserve_ac, returning nil bytes when none is found.
|
||||
HelicopterIDByInspection(ctx context.Context, inspectionID []byte) ([]byte, error)
|
||||
}
|
||||
33
internal/domain/fleet_status/const.go
Normal file
33
internal/domain/fleet_status/const.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package fleetstatus
|
||||
|
||||
const (
|
||||
InspectionTypeAF = "A/F"
|
||||
InspectionTypeENG1 = "ENG1"
|
||||
InspectionTypeENG2 = "ENG2"
|
||||
InspectionTypeMAIN1 = "MAIN1"
|
||||
InspectionTypeMAIN2 = "MAIN2"
|
||||
|
||||
FleetStatusFileRefType = "fleet_status"
|
||||
|
||||
StatusActive = "active"
|
||||
StatusServiced = "serviced"
|
||||
FileCategoryWB = "wb"
|
||||
FileCategoryBFI = "bfi"
|
||||
)
|
||||
|
||||
func IsValidFileCategory(s string) bool {
|
||||
switch s {
|
||||
case FileCategoryWB, FileCategoryBFI:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var InspectionTypeOrder = []string{
|
||||
InspectionTypeAF,
|
||||
InspectionTypeENG1,
|
||||
InspectionTypeENG2,
|
||||
InspectionTypeMAIN1,
|
||||
InspectionTypeMAIN2,
|
||||
}
|
||||
107
internal/domain/fleet_status/model.go
Normal file
107
internal/domain/fleet_status/model.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package fleetstatus
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FleetStatus struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
|
||||
Status string `gorm:"type:varchar(16);not null;default:active;index;column:status"`
|
||||
ServicedAt *time.Time `gorm:"column:serviced_at;index"`
|
||||
|
||||
MaintenanceSchedules []MaintenanceSchedule `gorm:"foreignKey:FleetStatusID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Files []FleetStatusFile `gorm:"foreignKey:FleetStatusID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (FleetStatus) TableName() string { return "fleet_statuses" }
|
||||
|
||||
func (f *FleetStatus) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if f.Status == "" {
|
||||
f.Status = StatusActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MaintenanceSchedule struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FleetStatusID []byte `gorm:"type:binary(16);not null;index;column:fleet_status_id"`
|
||||
InspectionType string `gorm:"type:varchar(16);not null;column:inspection_type"`
|
||||
Type string `gorm:"type:varchar(255);not null;column:type"`
|
||||
Due string `gorm:"type:varchar(255);column:due"`
|
||||
Ext string `gorm:"type:varchar(255);column:ext"`
|
||||
|
||||
FleetStatus *FleetStatus `gorm:"foreignKey:FleetStatusID;references:ID"`
|
||||
}
|
||||
|
||||
func (MaintenanceSchedule) TableName() string { return "maintenance_schedules" }
|
||||
|
||||
func (m *MaintenanceSchedule) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FleetStatusFile struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
IsMandatory bool `gorm:"not null;default:false;column:is_mandatory"`
|
||||
FleetStatusID []byte `gorm:"type:binary(16);not null;index;column:fleet_status_id"`
|
||||
FileAttachmentID []byte `gorm:"type:binary(16);not null;index;column:file_attachment_id"`
|
||||
Category string `gorm:"type:varchar(64);index;column:category"`
|
||||
|
||||
FileID []byte `gorm:"-"`
|
||||
|
||||
FleetStatus *FleetStatus `gorm:"foreignKey:FleetStatusID;references:ID"`
|
||||
Attachment *filemanager.Attachment `gorm:"foreignKey:FileAttachmentID;references:ID"`
|
||||
}
|
||||
|
||||
func (FleetStatusFile) TableName() string { return "fleet_status_files" }
|
||||
|
||||
func (f *FleetStatusFile) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FleetStatusServiceLog struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FleetStatusID []byte `gorm:"type:binary(16);not null;index;column:fleet_status_id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
|
||||
InspectionType string `gorm:"type:varchar(16);not null;column:inspection_type"`
|
||||
Type string `gorm:"type:varchar(255);not null;column:type"`
|
||||
Due string `gorm:"type:varchar(255);column:due"`
|
||||
Ext string `gorm:"type:varchar(255);column:ext"`
|
||||
ServicedAt time.Time `gorm:"column:serviced_at;index"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
}
|
||||
|
||||
func (FleetStatusServiceLog) TableName() string { return "fleet_status_service_logs" }
|
||||
|
||||
func (f *FleetStatusServiceLog) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
68
internal/domain/fleet_status/model_test.go
Normal file
68
internal/domain/fleet_status/model_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package fleetstatus
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTableNames(t *testing.T) {
|
||||
if (FleetStatus{}).TableName() != "fleet_statuses" {
|
||||
t.Error("FleetStatus table name")
|
||||
}
|
||||
if (MaintenanceSchedule{}).TableName() != "maintenance_schedules" {
|
||||
t.Error("MaintenanceSchedule table name")
|
||||
}
|
||||
if (FleetStatusFile{}).TableName() != "fleet_status_files" {
|
||||
t.Error("FleetStatusFile table name")
|
||||
}
|
||||
if (FleetStatusServiceLog{}).TableName() != "fleet_status_service_logs" {
|
||||
t.Error("FleetStatusServiceLog table name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetStatusBeforeCreate(t *testing.T) {
|
||||
f := &FleetStatus{}
|
||||
if err := f.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(f.ID) == 0 {
|
||||
t.Fatal("ID should be set")
|
||||
}
|
||||
if f.Status != StatusActive {
|
||||
t.Fatalf("Status default = %q, want %q", f.Status, StatusActive)
|
||||
}
|
||||
// existing ID + status preserved
|
||||
f2 := &FleetStatus{Status: "serviced"}
|
||||
_ = f2.BeforeCreate(nil)
|
||||
if f2.Status != "serviced" {
|
||||
t.Fatal("BeforeCreate must not overwrite an existing status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildBeforeCreate(t *testing.T) {
|
||||
m := &MaintenanceSchedule{}
|
||||
_ = m.BeforeCreate(nil)
|
||||
if len(m.ID) == 0 {
|
||||
t.Error("MaintenanceSchedule ID not set")
|
||||
}
|
||||
fsf := &FleetStatusFile{}
|
||||
_ = fsf.BeforeCreate(nil)
|
||||
if len(fsf.ID) == 0 {
|
||||
t.Error("FleetStatusFile ID not set")
|
||||
}
|
||||
sl := &FleetStatusServiceLog{}
|
||||
_ = sl.BeforeCreate(nil)
|
||||
if len(sl.ID) == 0 {
|
||||
t.Error("FleetStatusServiceLog ID not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidFileCategory(t *testing.T) {
|
||||
for _, c := range []string{FileCategoryWB, FileCategoryBFI} {
|
||||
if !IsValidFileCategory(c) {
|
||||
t.Fatalf("%q should be valid", c)
|
||||
}
|
||||
}
|
||||
for _, c := range []string{"WB", "", "nope"} {
|
||||
if IsValidFileCategory(c) {
|
||||
t.Fatalf("%q should be invalid", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
internal/domain/fleet_status/repository.go
Normal file
19
internal/domain/fleet_status/repository.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package fleetstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *FleetStatus) error
|
||||
Update(ctx context.Context, row *FleetStatus) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FleetStatus, error)
|
||||
List(ctx context.Context, filter, sort string, limit, offset int) ([]FleetStatus, int64, error)
|
||||
LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*FleetStatus, error)
|
||||
MarkServiced(ctx context.Context, id []byte, servicedAt time.Time, actorID []byte) error
|
||||
ListServiceHistoryByHelicopterID(ctx context.Context, helicopterID []byte, limit, offset int) ([]FleetStatusServiceLog, int64, error)
|
||||
HelicopterIDByMissionID(ctx context.Context, missionID []byte) ([]byte, error)
|
||||
HelicopterIDsWithFleetStatus(ctx context.Context) ([][]byte, error)
|
||||
}
|
||||
28
internal/domain/fleet_status/service.go
Normal file
28
internal/domain/fleet_status/service.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package fleetstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
helicopterusage "wucher/internal/domain/helicopter_usage"
|
||||
shareddto "wucher/internal/transport/http/dto/shared"
|
||||
)
|
||||
|
||||
type OverdueInspection struct {
|
||||
InspectionType string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *FleetStatus) error
|
||||
Update(ctx context.Context, row *FleetStatus) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FleetStatus, error)
|
||||
List(ctx context.Context, filter, sort string, limit, offset int) ([]FleetStatus, int64, error)
|
||||
LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*FleetStatus, error)
|
||||
MarkServiced(ctx context.Context, id []byte, servicedAt time.Time, actorID []byte) error
|
||||
ListServiceHistoryByHelicopterID(ctx context.Context, helicopterID []byte, limit, offset int) ([]FleetStatusServiceLog, int64, error)
|
||||
GetSummaryByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.FleetStatusSummary, error)
|
||||
ReviseSummary(ctx context.Context, helicopterID []byte, in helicopterusage.ManualSummaryInput, actor []byte) error
|
||||
OverdueInspections(ctx context.Context, helicopterID []byte) ([]OverdueInspection, error)
|
||||
}
|
||||
39
internal/domain/flight/model.go
Normal file
39
internal/domain/flight/model.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package flight
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/takeover"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Flight struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:draft;column:status"`
|
||||
Date time.Time `gorm:"type:date;not null;index;column:date"`
|
||||
MissionCode *string `gorm:"type:varchar(64);index;column:mission_code"`
|
||||
DutyRosterID []byte `gorm:"type:binary(16);->;column:duty_roster_id"`
|
||||
ReserveAcID []byte `gorm:"type:binary(16);->;column:reserve_ac_id"`
|
||||
TakeoverAcID []byte `gorm:"type:binary(16);index;column:takeover_ac_id"`
|
||||
Takeover *takeover.TakeoverAc `gorm:"foreignKey:TakeoverAcID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
CreatedByName string `gorm:"->;column:created_by_name"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
UpdatedByName string `gorm:"->;column:updated_by_name"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (Flight) TableName() string { return "flights" }
|
||||
|
||||
func (f *Flight) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
internal/domain/flight/repository.go
Normal file
20
internal/domain/flight/repository.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package flight
|
||||
|
||||
import "context"
|
||||
|
||||
type FlightRepository interface {
|
||||
Create(ctx context.Context, row *Flight) error
|
||||
GetLatestMissionCodeByPrefix(ctx context.Context, prefix string) (string, error)
|
||||
Update(ctx context.Context, row *Flight) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Flight, error)
|
||||
ListByUserID(ctx context.Context, userID []byte, limit, offset int) ([]Flight, int64, error)
|
||||
ListByCreatedBy(ctx context.Context, createdBy []byte, filter string, date string, sort string, limit, offset int) ([]Flight, int64, error)
|
||||
GetByReserveAcID(ctx context.Context, reserveAcID []byte) (*Flight, error)
|
||||
ListByReserveAcID(ctx context.Context, reserveAcID []byte) ([]Flight, error)
|
||||
GetByTakeoverAcID(ctx context.Context, takeoverAcID []byte) (*Flight, error)
|
||||
ListByTakeoverAcIDs(ctx context.Context, takeoverAcIDs [][]byte) ([]Flight, error)
|
||||
GetByDutyRosterID(ctx context.Context, dutyRosterID []byte) (*Flight, error)
|
||||
ListByDutyRosterIDs(ctx context.Context, dutyRosterIDs [][]byte) ([]Flight, error)
|
||||
List(ctx context.Context, filter string, date string, sort string, limit, offset int) ([]Flight, int64, error)
|
||||
}
|
||||
19
internal/domain/flight/service.go
Normal file
19
internal/domain/flight/service.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package flight
|
||||
|
||||
import "context"
|
||||
|
||||
type FlightService interface {
|
||||
Create(ctx context.Context, row *Flight) error
|
||||
Update(ctx context.Context, row *Flight) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Flight, error)
|
||||
ListByUserID(ctx context.Context, userID []byte, limit, offset int) ([]Flight, int64, error)
|
||||
ListByCreatedBy(ctx context.Context, createdBy []byte, filter string, date string, sort string, limit, offset int) ([]Flight, int64, error)
|
||||
GetByReserveAcID(ctx context.Context, reserveAcID []byte) (*Flight, error)
|
||||
ListByReserveAcID(ctx context.Context, reserveAcID []byte) ([]Flight, error)
|
||||
GetByTakeoverAcID(ctx context.Context, takeoverAcID []byte) (*Flight, error)
|
||||
ListByTakeoverAcIDs(ctx context.Context, takeoverAcIDs [][]byte) ([]Flight, error)
|
||||
GetByDutyRosterID(ctx context.Context, dutyRosterID []byte) (*Flight, error)
|
||||
ListByDutyRosterIDs(ctx context.Context, dutyRosterIDs [][]byte) ([]Flight, error)
|
||||
List(ctx context.Context, filter string, date string, sort string, limit, offset int) ([]Flight, int64, error)
|
||||
}
|
||||
121
internal/domain/flight_data/completion.go
Normal file
121
internal/domain/flight_data/completion.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package flightdata
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sharedconst "wucher/internal/shared/const"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusInProgress = "in_progress"
|
||||
StatusCompleted = "completed"
|
||||
)
|
||||
|
||||
// IsComplete reports whether the persisted flight-data status is complete.
|
||||
func IsComplete(row *FlightData) bool {
|
||||
if row == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(strings.TrimSpace(row.Status), StatusCompleted)
|
||||
}
|
||||
|
||||
func DeriveStatus(row *FlightData, details *SPODetails) string {
|
||||
if !isBaseComplete(row) {
|
||||
return StatusInProgress
|
||||
}
|
||||
|
||||
switch missionType(row) {
|
||||
case sharedconst.MissionTypeCAT, sharedconst.MissionTypeNCO, sharedconst.MissionTypeSPO:
|
||||
return StatusCompleted
|
||||
case sharedconst.MissionTypeHEMS:
|
||||
return StatusCompleted
|
||||
}
|
||||
|
||||
return StatusInProgress
|
||||
}
|
||||
|
||||
func hasExactlyOneLocation(a, b []byte) bool {
|
||||
hasA := len(a) == 16
|
||||
hasB := len(b) == 16
|
||||
return hasA != hasB
|
||||
}
|
||||
|
||||
func missionType(row *FlightData) string {
|
||||
if row == nil || row.Mission == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(row.Mission.Type))
|
||||
}
|
||||
|
||||
func isBaseComplete(row *FlightData) bool {
|
||||
if row == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
requiresRotorBrakeCycle := true
|
||||
switch missionType(row) {
|
||||
case sharedconst.MissionTypeCAT, sharedconst.MissionTypeNCO, sharedconst.MissionTypeSPO:
|
||||
requiresRotorBrakeCycle = false
|
||||
}
|
||||
|
||||
return len(row.MissionID) == 16 &&
|
||||
hasExactlyOneLocation(row.FromICAOID, row.FromHospitalID) &&
|
||||
hasExactlyOneLocation(row.ToICAOID, row.ToHospitalID) &&
|
||||
!row.FlightTakeOff.IsZero() &&
|
||||
!row.FlightLanding.IsZero() &&
|
||||
row.FlightDuration > 0 &&
|
||||
row.LandingCount > 0 &&
|
||||
(!requiresRotorBrakeCycle || row.RotorBrakeCycle > 0)
|
||||
}
|
||||
|
||||
func isHESLOComplete(details SPOHESLODetails) bool {
|
||||
if len(details.Flights) == 0 || len(details.Slings) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := range details.Flights {
|
||||
if details.Flights[i].ROT <= 0 || len(details.Flights[i].FuelTruckID) != 16 || len(details.Flights[i].LoggingSlingID) != 16 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := range details.Slings {
|
||||
if len(details.Slings[i].HESLOFlightID) != 16 || len(details.Slings[i].SlingID) != 16 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isLoggingComplete(details SPOLoggingDetails) bool {
|
||||
if len(details.Slings) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := range details.Slings {
|
||||
if len(details.Slings[i].SlingID) != 16 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isHECComplete(details SPOHECDetails) bool {
|
||||
if len(details.Flights) == 0 || len(details.Slings) == 0 || len(details.Loads) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := range details.Flights {
|
||||
if details.Flights[i].ROT <= 0 || details.Flights[i].HECCycle <= 0 || len(details.Flights[i].EquipmentID) != 16 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := range details.Slings {
|
||||
if len(details.Slings[i].SlingID) != 16 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := range details.Loads {
|
||||
if strings.TrimSpace(details.Loads[i].LoadCategory) == "" || details.Loads[i].Quantity <= 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
123
internal/domain/flight_data/completion_test.go
Normal file
123
internal/domain/flight_data/completion_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package flightdata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
missiondomain "wucher/internal/domain/mission"
|
||||
)
|
||||
|
||||
func TestIsComplete(t *testing.T) {
|
||||
if IsComplete(nil) {
|
||||
t.Fatal("expected nil row to be incomplete")
|
||||
}
|
||||
if IsComplete(&FlightData{Status: StatusInProgress}) {
|
||||
t.Fatal("expected on_progress status to be incomplete")
|
||||
}
|
||||
if !IsComplete(&FlightData{Status: StatusCompleted}) {
|
||||
t.Fatal("expected complete status to be complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatus(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
base := &FlightData{
|
||||
MissionID: []byte("1234567890123456"),
|
||||
FromICAOID: []byte("3456789012345678"),
|
||||
ToHospitalID: []byte("4567890123456789"),
|
||||
FlightTakeOff: now,
|
||||
FlightLanding: now,
|
||||
FlightDuration: time.Hour,
|
||||
LandingCount: 1,
|
||||
DeliveryNoteNumber: "DN-1",
|
||||
CustomerName: "Customer",
|
||||
}
|
||||
|
||||
t.Run("cat complete", func(t *testing.T) {
|
||||
base.Mission = &missiondomain.Mission{Type: "CAT"}
|
||||
if got := DeriveStatus(base, nil); got != StatusCompleted {
|
||||
t.Fatalf("expected complete, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nco complete without rotor brake cycle", func(t *testing.T) {
|
||||
base.Mission = &missiondomain.Mission{Type: "NCO"}
|
||||
if got := DeriveStatus(base, nil); got != StatusCompleted {
|
||||
t.Fatalf("expected complete, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hems requires rotor brake cycle", func(t *testing.T) {
|
||||
base.Mission = &missiondomain.Mission{Type: "HEMS"}
|
||||
if got := DeriveStatus(base, nil); got != StatusInProgress {
|
||||
t.Fatalf("expected on_progress, got %q", got)
|
||||
}
|
||||
base.RotorBrakeCycle = 1
|
||||
if got := DeriveStatus(base, nil); got != StatusCompleted {
|
||||
t.Fatalf("expected complete after rotor brake cycle is set, got %q", got)
|
||||
}
|
||||
base.RotorBrakeCycle = 0
|
||||
})
|
||||
|
||||
t.Run("spo complete without details", func(t *testing.T) {
|
||||
base.Mission = &missiondomain.Mission{Type: "SPO"}
|
||||
if got := DeriveStatus(base, nil); got != StatusCompleted {
|
||||
t.Fatalf("expected complete, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spo complete with details", func(t *testing.T) {
|
||||
base.Mission = &missiondomain.Mission{Type: "SPO"}
|
||||
details := &SPODetails{
|
||||
HESLO: SPOHESLODetails{
|
||||
Flights: []SPOHESLOFlight{{
|
||||
ROT: 1,
|
||||
FuelTruckID: []byte("5678901234567890"),
|
||||
LoggingSlingID: []byte("6789012345678901"),
|
||||
}},
|
||||
Slings: []SPOHESLOSling{{
|
||||
HESLOFlightID: []byte("5678901234567890"),
|
||||
SlingID: []byte("7890123456789012"),
|
||||
}},
|
||||
},
|
||||
Logging: SPOLoggingDetails{
|
||||
Slings: []SPOLoggingSling{{
|
||||
SlingID: []byte("8901234567890123"),
|
||||
}},
|
||||
},
|
||||
HEC: SPOHECDetails{
|
||||
Flights: []SPOHECFlight{{
|
||||
ROT: 1,
|
||||
HECCycle: 1,
|
||||
EquipmentID: []byte("9012345678901234"),
|
||||
}},
|
||||
Slings: []SPOHECSling{{
|
||||
SlingID: []byte("0123456789012345"),
|
||||
}},
|
||||
Loads: []SPOHECLoad{{
|
||||
LoadCategory: "LT200",
|
||||
Quantity: 1,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if got := DeriveStatus(base, details); got != StatusCompleted {
|
||||
t.Fatalf("expected complete, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spo missing section still complete", func(t *testing.T) {
|
||||
base.Mission = &missiondomain.Mission{Type: "SPO"}
|
||||
details := &SPODetails{
|
||||
HESLO: SPOHESLODetails{
|
||||
Flights: []SPOHESLOFlight{{
|
||||
ROT: 1,
|
||||
FuelTruckID: []byte("5678901234567890"),
|
||||
LoggingSlingID: []byte("6789012345678901"),
|
||||
}},
|
||||
},
|
||||
}
|
||||
if got := DeriveStatus(base, details); got != StatusCompleted {
|
||||
t.Fatalf("expected complete, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
32
internal/domain/flight_data/errors.go
Normal file
32
internal/domain/flight_data/errors.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package flightdata
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrRequired = errors.New("flight_data required")
|
||||
ErrMissionReference = errors.New("flight_data mission reference invalid")
|
||||
ErrCoPilotReference = errors.New("flight_data co_pilot reference invalid")
|
||||
ErrOriginLocationReference = errors.New("flight_data origin location reference invalid")
|
||||
ErrDestinationLocationReference = errors.New("flight_data destination location reference invalid")
|
||||
ErrFacilityReference = errors.New("flight_data facility reference invalid")
|
||||
ErrCreateBlocked = errors.New("flight_data create blocked")
|
||||
ErrLocked = errors.New("flight_data locked")
|
||||
ErrLockedAfterFlightInspection = errors.New("flight_data locked by after_flight_inspection")
|
||||
)
|
||||
|
||||
const (
|
||||
ErrorMessageRequired = "flight data is required"
|
||||
ErrorMessageMissionReference = "mission must reference an existing mission"
|
||||
ErrorMessageCoPilotReference = "co_pilot must reference an existing user"
|
||||
ErrorMessageOriginLocationReference = "origin must reference exactly one location type"
|
||||
ErrorMessageDestinationLocationReference = "destination must reference exactly one location type"
|
||||
ErrorMessageFacilityReference = "facility reference is invalid"
|
||||
ErrorMessageFieldRequired = "field is required"
|
||||
ErrorMessageInvalidUUID = "invalid UUID"
|
||||
ErrorMessageInvalidRFC3339 = "invalid RFC3339 datetime"
|
||||
ErrorMessageInvalidPathUUID = "uuid is invalid UUID"
|
||||
ErrorMessageNotFound = "flight data not found"
|
||||
ErrorMessageCreateBlocked = "flight data cannot be created because fm report already exists for this flight"
|
||||
ErrorMessageLocked = "flight data is locked because fm report is completed"
|
||||
ErrorMessageLockedAfterFlightInspection = "flight data is locked because after flight inspection already exists"
|
||||
)
|
||||
281
internal/domain/flight_data/flight_data.go
Normal file
281
internal/domain/flight_data/flight_data.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package flightdata
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
facilitydomain "wucher/internal/domain/facility"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
hospitaldomain "wucher/internal/domain/hospital"
|
||||
icaodomain "wucher/internal/domain/icao"
|
||||
missiondomain "wucher/internal/domain/mission"
|
||||
userdomain "wucher/internal/domain/user"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FlightData struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
MissionID []byte `gorm:"type:binary(16);index;not null;column:mission_id"`
|
||||
CoPilotID []byte `gorm:"type:binary(16);index;column:co_pilot_id"`
|
||||
|
||||
FromICAOID []byte `gorm:"type:binary(16);index;column:from_icao_id"`
|
||||
FromHospitalID []byte `gorm:"type:binary(16);index;column:from_hospital_id"`
|
||||
|
||||
ToICAOID []byte `gorm:"type:binary(16);index;column:to_icao_id"`
|
||||
ToHospitalID []byte `gorm:"type:binary(16);index;column:to_hospital_id"`
|
||||
|
||||
FlightTakeOff time.Time `gorm:"column:take_off"`
|
||||
FlightLanding time.Time `gorm:"column:landing"`
|
||||
FlightDuration time.Duration `gorm:"column:duration"`
|
||||
FlightType string `gorm:"type:varchar(255);column:flight_type"`
|
||||
FlightRED time.Duration `gorm:"column:red"`
|
||||
MaxN1 float64 `gorm:"column:max_n1"`
|
||||
MaxN2 float64 `gorm:"column:max_n2"`
|
||||
PaxCount int `gorm:"column:pax_count"`
|
||||
TicketNo string `gorm:"type:varchar(255);column:ticket_no"`
|
||||
Engine string `gorm:"type:varchar(255);column:engine"`
|
||||
LandingCount int `gorm:"column:landing_count"`
|
||||
RotorBrakeCycle int `gorm:"column:rotor_brake_cycle"`
|
||||
HookReleases int `gorm:"column:hook_releases"`
|
||||
DeliveryNoteNumber string `gorm:"type:varchar(255);column:delivery_note_number"`
|
||||
CustomerName string `gorm:"type:varchar(255);column:customer_name"`
|
||||
FlightPlanDistance float64 `gorm:"column:flight_plan_distance"`
|
||||
FlightPlanTime time.Duration `gorm:"column:flight_plan_time"`
|
||||
FlightPlanTrueCourse float64 `gorm:"column:flight_plan_true_course"`
|
||||
FuelBeforeFlight float64 `gorm:"column:fuel_before_flight"`
|
||||
FuelUpload float64 `gorm:"column:fuel_upload"`
|
||||
FuelAfterFlight float64 `gorm:"column:fuel_after_flight"`
|
||||
FuelPlanning float64 `gorm:"column:fuel_planning"`
|
||||
FlightPositioning bool `gorm:"type:boolean;not null;default:false;column:flight_position"`
|
||||
OtherInformation string `gorm:"type:text;column:other_information"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:on_progress;column:status"`
|
||||
|
||||
Mission *missiondomain.Mission `gorm:"foreignKey:MissionID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
TypeOfFlight *TypeOfFlight `gorm:"foreignKey:FlightDataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
CoPilot *userdomain.User `gorm:"foreignKey:CoPilotID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
FromICAO *icaodomain.ICAO `gorm:"foreignKey:FromICAOID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
FromHospital *hospitaldomain.Hospital `gorm:"foreignKey:FromHospitalID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
ToICAO *icaodomain.ICAO `gorm:"foreignKey:ToICAOID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
ToHospital *hospitaldomain.Hospital `gorm:"foreignKey:ToHospitalID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
Attachments []filemanager.Attachment `gorm:"-"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"index;column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
}
|
||||
|
||||
func (FlightData) TableName() string { return "flight_data" }
|
||||
|
||||
func (f *FlightData) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TypeOfFlight struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightDataID []byte `gorm:"type:binary(16);index;not null;column:flight_data_id"`
|
||||
Category string `gorm:"type:enum('ZNO', 'ANO', 'ZTZ', 'ZSP');not null;column:category"`
|
||||
Quantity int `gorm:"not null;column:quantity"`
|
||||
DurationTime time.Duration `gorm:"not null;column:duration"`
|
||||
|
||||
FlightData *FlightData `gorm:"foreignKey:FlightDataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type HESLOFlight struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightDataID []byte `gorm:"type:binary(16);index;not null;column:flight_data_id"`
|
||||
ROT int `gorm:"not null;column:rot"`
|
||||
FacilityFuelTruckID []byte `gorm:"type:binary(16);column:fuel_truck_id"`
|
||||
LoggingSlingID []byte `gorm:"type:binary(16);column:logging_sling_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
|
||||
FlightData *FlightData `gorm:"foreignKey:FlightDataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
FacilityFuelTruck *facilitydomain.Facility `gorm:"foreignKey:FacilityFuelTruckID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
LoggingSling *facilitydomain.Facility `gorm:"foreignKey:LoggingSlingID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
}
|
||||
|
||||
func (HESLOFlight) TableName() string { return "heslo_flights" }
|
||||
|
||||
type HESLOSling struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FacilitySlingID []byte `gorm:"type:binary(16);column:facility_sling_id"`
|
||||
HESLOFlightID []byte `gorm:"type:binary(16);index;not null;column:heslo_flight_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
|
||||
HESLOFlight *HESLOFlight `gorm:"foreignKey:HESLOFlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
FacilitySling *facilitydomain.Facility `gorm:"foreignKey:FacilitySlingID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
}
|
||||
|
||||
func (HESLOSling) TableName() string { return "heslo_slings" }
|
||||
|
||||
type LoggingSling struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightDataID []byte `gorm:"type:binary(16);index;not null;column:flight_data_id"`
|
||||
FacilitySlingID []byte `gorm:"type:binary(16);index;column:facility_sling_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
|
||||
FacilitySling *facilitydomain.Facility `gorm:"foreignKey:FacilitySlingID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
FlightData *FlightData `gorm:"foreignKey:FlightDataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (LoggingSling) TableName() string { return "logging_slings" }
|
||||
|
||||
type HECFlight struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightDataID []byte `gorm:"type:binary(16);index;not null;column:flight_data_id"`
|
||||
ROT int `gorm:"not null;column:rot"`
|
||||
HECCycle int `gorm:"not null;column:hec_cycle"`
|
||||
EquipmentID []byte `gorm:"type:binary(16);column:equipment_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
|
||||
FlightData *FlightData `gorm:"foreignKey:FlightDataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Equipment *facilitydomain.Facility `gorm:"foreignKey:EquipmentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
}
|
||||
|
||||
func (HECFlight) TableName() string { return "hec_flights" }
|
||||
|
||||
type HECSling struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightDataID []byte `gorm:"type:binary(16);index;not null;column:flight_data_id"`
|
||||
HECSlingID []byte `gorm:"type:binary(16);column:hec_sling_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
|
||||
FlightData *FlightData `gorm:"foreignKey:FlightDataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
HECSling *facilitydomain.Facility `gorm:"foreignKey:HECSlingID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
}
|
||||
|
||||
func (HECSling) TableName() string { return "hec_slings" }
|
||||
|
||||
type HECLoads struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightDataID []byte `gorm:"type:binary(16);index;not null;column:flight_data_id"`
|
||||
LoadCategory string `gorm:"type:varchar(20);not null;column:load_category"`
|
||||
Quantity int `gorm:"not null;column:quantity"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
|
||||
FlightData *FlightData `gorm:"foreignKey:FlightDataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (HECLoads) TableName() string { return "hec_loads" }
|
||||
|
||||
type SPODetails struct {
|
||||
HESLO SPOHESLODetails
|
||||
Logging SPOLoggingDetails
|
||||
HEC SPOHECDetails
|
||||
}
|
||||
|
||||
type SPOHESLODetails struct {
|
||||
Flights []SPOHESLOFlight
|
||||
Slings []SPOHESLOSling
|
||||
}
|
||||
|
||||
type SPOHESLOFlight struct {
|
||||
ID []byte
|
||||
ROT int
|
||||
FuelTruckID []byte
|
||||
FuelTruckName string
|
||||
LoggingSlingID []byte
|
||||
LoggingSlingName string
|
||||
}
|
||||
|
||||
type SPOHESLOSling struct {
|
||||
ID []byte
|
||||
HESLOFlightID []byte
|
||||
SlingID []byte
|
||||
SlingName string
|
||||
}
|
||||
|
||||
type SPOLoggingDetails struct {
|
||||
Slings []SPOLoggingSling
|
||||
}
|
||||
|
||||
type SPOLoggingSling struct {
|
||||
ID []byte
|
||||
SlingID []byte
|
||||
SlingName string
|
||||
}
|
||||
|
||||
type SPOHECDetails struct {
|
||||
Flights []SPOHECFlight
|
||||
Slings []SPOHECSling
|
||||
Loads []SPOHECLoad
|
||||
}
|
||||
|
||||
type SPOHECFlight struct {
|
||||
ID []byte
|
||||
ROT int
|
||||
HECCycle int
|
||||
EquipmentID []byte
|
||||
EquipmentName string
|
||||
}
|
||||
|
||||
type SPOHECSling struct {
|
||||
ID []byte
|
||||
SlingID []byte
|
||||
SlingName string
|
||||
}
|
||||
|
||||
type SPOHECLoad struct {
|
||||
ID []byte
|
||||
LoadCategory string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// SPO upsert input types (write path)
|
||||
|
||||
type SPOUpsertData struct {
|
||||
HESLO *SPOHESLOUpsert
|
||||
Logging *SPOLoggingUpsert
|
||||
HEC *SPOHECUpsert
|
||||
}
|
||||
|
||||
type SPOHESLOUpsert struct {
|
||||
Flights []SPOHESLOFlightUpsert
|
||||
}
|
||||
|
||||
type SPOHESLOFlightUpsert struct {
|
||||
ROT int
|
||||
FuelTruckID []byte
|
||||
LoggingSlingID []byte
|
||||
Slings []SPOHESLOSlingUpsert
|
||||
}
|
||||
|
||||
type SPOHESLOSlingUpsert struct {
|
||||
SlingID []byte
|
||||
}
|
||||
|
||||
type SPOLoggingUpsert struct {
|
||||
Slings []SPOLoggingSlingUpsert
|
||||
}
|
||||
|
||||
type SPOLoggingSlingUpsert struct {
|
||||
SlingID []byte
|
||||
}
|
||||
|
||||
type SPOHECUpsert struct {
|
||||
Flights []SPOHECFlightUpsert
|
||||
Slings []SPOHECSlingUpsert
|
||||
Loads []SPOHECLoadUpsert
|
||||
}
|
||||
|
||||
type SPOHECFlightUpsert struct {
|
||||
ROT int
|
||||
HECCycle int
|
||||
EquipmentID []byte
|
||||
}
|
||||
|
||||
type SPOHECSlingUpsert struct {
|
||||
SlingID []byte
|
||||
}
|
||||
|
||||
type SPOHECLoadUpsert struct {
|
||||
LoadCategory string
|
||||
Quantity int
|
||||
}
|
||||
18
internal/domain/flight_data/repository.go
Normal file
18
internal/domain/flight_data/repository.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package flightdata
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *FlightData) error
|
||||
Update(ctx context.Context, row *FlightData) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FlightData, error)
|
||||
GetByFlightID(ctx context.Context, flightID []byte) (*FlightData, error)
|
||||
GetByMissionID(ctx context.Context, missionID []byte) (*FlightData, error)
|
||||
ListByMissionID(ctx context.Context, missionID []byte) ([]FlightData, error)
|
||||
List(ctx context.Context, date string, missionID, flightDataID []byte, limit, offset int) ([]FlightData, int64, error)
|
||||
GetSPODetailsByFlightDataID(ctx context.Context, flightDataID []byte) (*SPODetails, error)
|
||||
UpsertSPODetails(ctx context.Context, flightDataID []byte, data *SPOUpsertData) error
|
||||
}
|
||||
19
internal/domain/flight_data/service.go
Normal file
19
internal/domain/flight_data/service.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package flightdata
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *FlightData) error
|
||||
CreatePlaceholder(ctx context.Context, row *FlightData) error
|
||||
Update(ctx context.Context, row *FlightData) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FlightData, error)
|
||||
GetByFlightID(ctx context.Context, flightID []byte) (*FlightData, error)
|
||||
GetByMissionID(ctx context.Context, missionID []byte) (*FlightData, error)
|
||||
ListByMissionID(ctx context.Context, missionID []byte) ([]FlightData, error)
|
||||
List(ctx context.Context, date string, missionID, flightDataID []byte, limit, offset int) ([]FlightData, int64, error)
|
||||
GetSPODetailsByFlightDataID(ctx context.Context, flightDataID []byte) (*SPODetails, error)
|
||||
UpsertSPODetails(ctx context.Context, flightDataID []byte, data *SPOUpsertData) error
|
||||
}
|
||||
35
internal/domain/flight_inspection/model.go
Normal file
35
internal/domain/flight_inspection/model.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package flightinspection
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusDraft = "Draft"
|
||||
StatusInProgress = "InProgress"
|
||||
StatusCompleted = "Completed"
|
||||
)
|
||||
|
||||
type FlightInspection struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
InspectionDate time.Time `gorm:"type:date;not null;index;column:inspection_date"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:Draft;index;check:chk_flight_inspection_status,status IN ('Draft','InProgress','Completed');column:status"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (FlightInspection) TableName() string { return "flight_inspections" }
|
||||
|
||||
func (f *FlightInspection) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
internal/domain/flight_inspection/repository.go
Normal file
13
internal/domain/flight_inspection/repository.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package flightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type FlightInspectionRepository interface {
|
||||
Create(ctx context.Context, row *FlightInspection) error
|
||||
Update(ctx context.Context, row *FlightInspection) error
|
||||
GetByID(ctx context.Context, id []byte) (*FlightInspection, error)
|
||||
List(ctx context.Context, sort string, limit, offset int) ([]FlightInspection, int64, error)
|
||||
}
|
||||
|
||||
// Repository is kept as backward-compatible alias for older services/wirings.
|
||||
type Repository = FlightInspectionRepository
|
||||
10
internal/domain/flight_inspection/service.go
Normal file
10
internal/domain/flight_inspection/service.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package flightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type FlightInspectionService interface {
|
||||
CreateDraft(ctx context.Context, row *FlightInspection) error
|
||||
Update(ctx context.Context, row *FlightInspection) error
|
||||
GetByID(ctx context.Context, id []byte) (*FlightInspection, error)
|
||||
List(ctx context.Context, sort string, limit, offset int) ([]FlightInspection, int64, error)
|
||||
}
|
||||
37
internal/domain/flight_inspection_file_checklist/model.go
Normal file
37
internal/domain/flight_inspection_file_checklist/model.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package flightinspectionfilechecklist
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
helicopterfile "wucher/internal/domain/helicopter_file"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FlightInspectionFileChecklist struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
|
||||
IsDone bool `gorm:"type:tinyint(1);not null;default:0;index:idx_flight_inspection_file_checklists_inspection_sorted;column:is_done"`
|
||||
|
||||
HelicopterFileID []byte `gorm:"type:binary(16);not null;index:idx_flight_inspection_file_checklists_file;uniqueIndex:uidx_flight_inspection_file_checklists_inspection_file,priority:2;column:helicopter_file_id"`
|
||||
FlightInspectionID []byte `gorm:"type:binary(16);not null;index:idx_flight_inspection_file_checklists_inspection;index:idx_flight_inspection_file_checklists_inspection_sorted;uniqueIndex:uidx_flight_inspection_file_checklists_inspection_file,priority:1;column:flight_inspection_id"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
HelicopterFile *helicopterfile.HelicopterFile `gorm:"foreignKey:HelicopterFileID;references:ID"`
|
||||
FlightInspection *flightinspection.FlightInspection `gorm:"foreignKey:FlightInspectionID;references:ID"`
|
||||
}
|
||||
|
||||
func (FlightInspectionFileChecklist) TableName() string { return "flight_inspection_file_checklists" }
|
||||
|
||||
func (m *FlightInspectionFileChecklist) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package flightinspectionfilechecklist
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, row *FlightInspectionFileChecklist) error
|
||||
Update(ctx context.Context, row *FlightInspectionFileChecklist) error
|
||||
Delete(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FlightInspectionFileChecklist, error)
|
||||
List(ctx context.Context, filter string, sort string, limit, offset int) ([]FlightInspectionFileChecklist, int64, error)
|
||||
ListByInspectionID(ctx context.Context, inspectionID []byte) ([]FlightInspectionFileChecklist, error)
|
||||
FlightInspectionExists(ctx context.Context, inspectionID []byte) (bool, error)
|
||||
HelicopterFileExists(ctx context.Context, helicopterFileID []byte) (bool, error)
|
||||
}
|
||||
14
internal/domain/flight_inspection_file_checklist/service.go
Normal file
14
internal/domain/flight_inspection_file_checklist/service.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package flightinspectionfilechecklist
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, row *FlightInspectionFileChecklist) error
|
||||
Update(ctx context.Context, row *FlightInspectionFileChecklist) error
|
||||
Delete(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*FlightInspectionFileChecklist, error)
|
||||
List(ctx context.Context, filter string, sort string, limit, offset int) ([]FlightInspectionFileChecklist, int64, error)
|
||||
ListByInspectionID(ctx context.Context, inspectionID []byte) ([]FlightInspectionFileChecklist, error)
|
||||
FlightInspectionExists(ctx context.Context, inspectionID []byte) (bool, error)
|
||||
HelicopterFileExists(ctx context.Context, helicopterFileID []byte) (bool, error)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user