init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
package actionsignoff
import "errors"
var ErrNotFound = errors.New("action sign-off not found")

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

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

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

View 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")
)

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
package audit
import "context"
type Repository interface {
Create(ctx context.Context, entry *AuditLog) error
}

View 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"
)

View 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" }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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"`
}

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

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

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

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

View File

@@ -0,0 +1,5 @@
package contact
import "errors"
var ErrUsernameAlreadyExists = errors.New("username already exists")

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

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

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

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

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

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

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

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

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

View 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")
)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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,
}

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

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

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

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

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

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

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

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

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

View 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"
)

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

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

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

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

View 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

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

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

View File

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

View 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