package dto import ( "context" "strings" "time" "wucher/internal/domain/base" "wucher/internal/shared/pkg/userctx" "wucher/internal/shared/pkg/uuidv7" ) // JSON:API Base DTOs type BaseAttributes struct { BaseName string `json:"base" example:"Lude"` BaseAbbreviation string `json:"base_abbreviation,omitempty" example:"LOIG"` BaseCategory string `json:"base_category,omitempty" example:"regular" enums:"regular,hems"` Address string `json:"address,omitempty" example:"Lude Airfield"` Latitude float64 `json:"latitude" example:"47.3769"` Longitude float64 `json:"longitude" example:"8.5417"` LandlineNumber string `json:"landline_number,omitempty" example:"+62-21-1234"` MobileNumber string `json:"mobile_number,omitempty" example:"+62-812-1234"` Email string `json:"email,omitempty" example:"lude@wucher.local"` SortKey *int `json:"sortkey" example:"1"` SMSAlert bool `json:"sms_alert" example:"false"` Checklist bool `json:"checklist" example:"false"` LegTime string `json:"leg_time,omitempty" example:"00:20"` DefaultStartTimeType string `json:"default_start_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` DefaultEndTimeType string `json:"default_end_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` ShiftTime BaseShiftTime `json:"shift_time"` UTC string `json:"utc" example:"0"` Foto *BaseFoto `json:"foto,omitempty"` Dry bool `json:"dry" example:"false"` ControlCenter bool `json:"control_center" example:"false"` DUL bool `json:"dul" example:"false"` Notes string `json:"notes,omitempty" example:"Main base"` HEMSEDCContactIDs []string `json:"hems_edc_contact_ids,omitempty"` HEMSEDCs []BaseContact `json:"hems_edc,omitempty"` MedPaxContactIDs []string `json:"med_pax_contact_ids,omitempty"` MedPax []BaseContact `json:"med_pax,omitempty"` ResponsiblePilotContactIDs []string `json:"responsible_pilot_contact_ids,omitempty"` ResponsiblePilots []BaseContact `json:"responsible_pilots,omitempty"` OperationalShiftTimes []BaseOperationalShiftTime `json:"operational_shift_times,omitempty"` IsActive bool `json:"is_active" example:"true"` CreatedBy string `json:"created_by,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"` UpdatedBy string `json:"updated_by,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"` CreatedAt string `json:"created_at,omitempty" example:"2026-03-12T03:04:05Z"` UpdatedAt string `json:"updated_at,omitempty" example:"2026-03-12T03:04:05Z"` DeletedAt string `json:"deleted_at,omitempty" example:"2026-03-12T03:04:05Z" swaggerignore:"true"` DeletedBy string `json:"deleted_by,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d" swaggerignore:"true"` } type BaseContact struct { ID string `json:"id"` FirstName string `json:"first_name,omitempty"` LastName string `json:"last_name,omitempty"` } type BaseShiftTime struct { Start string `json:"start,omitempty" example:"06:00"` End string `json:"end,omitempty" example:"21:00"` } type BaseOperationalShiftTime struct { DateStart string `json:"date_start" example:"2026-04-12"` DateEnd string `json:"date_end" example:"2026-04-12"` StartTimeType string `json:"start_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` EndTimeType string `json:"end_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` ShiftTime BaseShiftTime `json:"shift_time"` } type BaseFoto struct { UUID string `json:"uuid" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"` DownloadURL string `json:"download_url" example:"https://storage.example.com/base/lude.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=abc123"` ThumbnailDownloadURL string `json:"thumbnail_download_url,omitempty" example:"https://storage.example.com/base/lude.thumb.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=abc123"` OriginalSizeBytes *int64 `json:"original_size_bytes,omitempty" example:"1024000"` } type BaseResource struct { Type string `json:"type" example:"base"` ID string `json:"id" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"` Attributes BaseAttributes `json:"attributes"` } type BaseResponse struct { Data BaseResource `json:"data"` } type BaseListResponse struct { Data []BaseResource `json:"data"` Meta struct { PageNumber int `json:"page_number" example:"1"` PageSize int `json:"page_size" example:"20"` Total int64 `json:"total" example:"120"` } `json:"meta"` } type BaseDataTableResponse struct { Data []BaseResource `json:"data"` Meta struct { Draw int `json:"draw" example:"1"` RecordsTotal int64 `json:"records_total" example:"120"` RecordsFiltered int64 `json:"records_filtered" example:"12"` } `json:"meta"` } type BaseCreateAttributes struct { BaseName string `json:"base" validate:"required" example:"Lude"` BaseAbbreviation *string `json:"base_abbreviation,omitempty" example:"LOIG"` BaseCategory *string `json:"base_category,omitempty" example:"regular" enums:"regular,hems"` Address *string `json:"address,omitempty" example:"Lude Airfield"` Latitude *float64 `json:"latitude" validate:"required,gte=-90,lte=90" example:"47.3769"` Longitude *float64 `json:"longitude" validate:"required,gte=-180,lte=180" example:"8.5417"` LandlineNumber *string `json:"landline_number,omitempty" example:"+62-21-1234"` MobileNumber *string `json:"mobile_number,omitempty" example:"+62-812-1234"` Email *string `json:"email,omitempty" example:"lude@wucher.local"` SortKey *int `json:"sortkey" example:"1"` SMSAlert *bool `json:"sms_alert,omitempty" example:"false"` Checklist *bool `json:"checklist,omitempty" example:"false"` LegTime *string `json:"leg_time,omitempty" example:"00:20"` DefaultStartTimeType *string `json:"default_start_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` DefaultEndTimeType *string `json:"default_end_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` ShiftTime *BaseShiftTime `json:"shift_time,omitempty"` DefaultShiftStart *string `json:"default_shift_start,omitempty" example:"06:00:00"` DefaultShiftEnd *string `json:"default_shift_end,omitempty" example:"21:00:00"` DefaultShiftTime *string `json:"default_shift_time,omitempty" example:"12:00"` UTC *string `json:"utc,omitempty" example:"0"` FileUUID *string `json:"file_uuid,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e90"` Dry *bool `json:"dry,omitempty" example:"false"` ControlCenter *bool `json:"control_center,omitempty" example:"false"` DUL *bool `json:"dul,omitempty" example:"false"` Notes *string `json:"notes,omitempty" example:"Main base"` HEMSEDCContactIDs []string `json:"hems_edc_contact_ids,omitempty"` MedPaxContactIDs []string `json:"med_pax_contact_ids,omitempty"` ResponsiblePilotContactIDs []string `json:"responsible_pilot_contact_ids,omitempty"` OperationalShiftTimes []BaseOperationalShiftTime `json:"operational_shift_times,omitempty"` IsActive *bool `json:"is_active,omitempty" example:"true"` CreatedBy *string `json:"created_by,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d" swaggerignore:"true"` UpdatedBy *string `json:"updated_by,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d" swaggerignore:"true"` } type BaseCreateData struct { Type string `json:"type" validate:"required,oneof=base_create base hems_base" enums:"base" example:"base"` Attributes BaseCreateAttributes `json:"attributes" validate:"required"` } type BaseCreateRequest struct { Data BaseCreateData `json:"data" validate:"required"` } type BaseUpdateAttributes struct { BaseName *string `json:"base,omitempty" example:"Lude"` BaseAbbreviation *string `json:"base_abbreviation,omitempty" example:"LOIG"` BaseCategory *string `json:"base_category,omitempty" example:"regular" enums:"regular,hems"` Address *string `json:"address,omitempty" example:"Lude Airfield"` Latitude *float64 `json:"latitude,omitempty" validate:"omitempty,gte=-90,lte=90" example:"47.3769"` Longitude *float64 `json:"longitude,omitempty" validate:"omitempty,gte=-180,lte=180" example:"8.5417"` LandlineNumber *string `json:"landline_number,omitempty" example:"+62-21-1234"` MobileNumber *string `json:"mobile_number,omitempty" example:"+62-812-1234"` Email *string `json:"email,omitempty" example:"lude@wucher.local"` SortKey NullInt `json:"sortkey,omitempty" swaggertype:"integer" example:"1"` SMSAlert *bool `json:"sms_alert,omitempty" example:"false"` Checklist *bool `json:"checklist,omitempty" example:"false"` LegTime *string `json:"leg_time,omitempty" example:"00:20"` DefaultStartTimeType *string `json:"default_start_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` DefaultEndTimeType *string `json:"default_end_time_type,omitempty" example:"FIXED" enums:"FIXED,BMCT,ECET"` DefaultShiftStart *string `json:"default_shift_start,omitempty" example:"06:00:00"` DefaultShiftEnd *string `json:"default_shift_end,omitempty" example:"21:00:00"` DefaultShiftTime *string `json:"default_shift_time,omitempty" example:"12:00"` ShiftTime *BaseShiftTime `json:"shift_time,omitempty"` UTC *string `json:"utc,omitempty" example:"0"` FileUUID *string `json:"file_uuid,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e90"` Dry *bool `json:"dry,omitempty" example:"false"` ControlCenter *bool `json:"control_center,omitempty" example:"false"` DUL *bool `json:"dul,omitempty" example:"false"` Notes *string `json:"notes,omitempty" example:"Main base"` HEMSEDCContactIDs *[]string `json:"hems_edc_contact_ids,omitempty"` MedPaxContactIDs *[]string `json:"med_pax_contact_ids,omitempty"` ResponsiblePilotContactIDs *[]string `json:"responsible_pilot_contact_ids,omitempty"` OperationalShiftTimes []BaseOperationalShiftTime `json:"operational_shift_times,omitempty"` IsActive *bool `json:"is_active,omitempty" example:"true"` CreatedBy *string `json:"created_by,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d" swaggerignore:"true"` UpdatedBy *string `json:"updated_by,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d" swaggerignore:"true"` } type BaseUpdateData struct { Type string `json:"type" validate:"required,oneof=base_update base hems_base" enums:"base_update" example:"base_update"` ID string `json:"id,omitempty" example:"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"` Attributes BaseUpdateAttributes `json:"attributes" validate:"required"` } type BaseUpdateRequest struct { Data BaseUpdateData `json:"data" validate:"required"` } type BaseDeleteAttributes struct { Deleted bool `json:"deleted" example:"true"` } type BaseDeleteResource struct { Type string `json:"type" example:"base_delete"` ID string `json:"id" example:"019d6b10-a385-7e9e-a553-8e892bcec0ef"` Attributes BaseDeleteAttributes `json:"attributes"` } type BaseDeleteResponse struct { Data BaseDeleteResource `json:"data"` } type BaseResponseMappingInput struct { ResourceType string BaseCategory string ID string DefaultStartTimeType string DefaultEndTimeType string ShiftTime BaseShiftTime Foto *BaseFoto Latitude float64 Longitude float64 HEMSEDCContactIDs []string HEMSEDCs []BaseContact MedPaxContactIDs []string MedPax []BaseContact ResponsiblePilotContactIDs []string ResponsiblePilots []BaseContact OperationalShiftTimes []BaseOperationalShiftTime } func MapBaseCreateAttributesToDomain(attrs BaseCreateAttributes) *base.Base { row := &base.Base{} row.BaseName = strings.TrimSpace(attrs.BaseName) row.BaseAbbreviation = strings.TrimSpace(stringOrDefault(attrs.BaseAbbreviation, "")) row.Address = strings.TrimSpace(stringOrDefault(attrs.Address, "")) row.Latitude = floatOrDefault(attrs.Latitude, 0) row.Longitude = floatOrDefault(attrs.Longitude, 0) row.LandlineNumber = strings.TrimSpace(stringOrDefault(attrs.LandlineNumber, "")) row.MobileNumber = strings.TrimSpace(stringOrDefault(attrs.MobileNumber, "")) row.Email = strings.TrimSpace(stringOrDefault(attrs.Email, "")) row.SortKey = cloneIntPtr(attrs.SortKey) row.SMSAlert = boolOrDefault(attrs.SMSAlert, false) row.Checklist = boolOrDefault(attrs.Checklist, false) row.LegTime = strings.TrimSpace(stringOrDefault(attrs.LegTime, "")) row.DefaultStartTimeType = normalizeShiftTimeType(stringOrDefault(attrs.DefaultStartTimeType, "")) row.DefaultEndTimeType = normalizeShiftTimeType(stringOrDefault(attrs.DefaultEndTimeType, "")) row.UTC = normalizeUTC(attrs.UTC) row.Dry = boolOrDefault(attrs.Dry, false) row.ControlCenter = boolOrDefault(attrs.ControlCenter, false) row.DUL = boolOrDefault(attrs.DUL, false) row.Notes = strings.TrimSpace(stringOrDefault(attrs.Notes, "")) row.IsActive = boolOrDefault(attrs.IsActive, true) row.OperationalShiftTimes = mapBaseOperationalShiftTimesToDomain(attrs.OperationalShiftTimes) return row } func ApplyBaseUpdateAttributesToDomain(row *base.Base, attrs BaseUpdateAttributes) { if attrs.BaseName != nil { row.BaseName = strings.TrimSpace(*attrs.BaseName) } if attrs.BaseAbbreviation != nil { row.BaseAbbreviation = strings.TrimSpace(*attrs.BaseAbbreviation) } if attrs.Address != nil { row.Address = strings.TrimSpace(*attrs.Address) } if attrs.Latitude != nil { row.Latitude = *attrs.Latitude } if attrs.Longitude != nil { row.Longitude = *attrs.Longitude } if attrs.LandlineNumber != nil { row.LandlineNumber = strings.TrimSpace(*attrs.LandlineNumber) } if attrs.MobileNumber != nil { row.MobileNumber = strings.TrimSpace(*attrs.MobileNumber) } if attrs.Email != nil { row.Email = strings.TrimSpace(*attrs.Email) } if attrs.SortKey.Set { if attrs.SortKey.Valid { v := attrs.SortKey.Value row.SortKey = &v } else { row.SortKey = nil } } if attrs.SMSAlert != nil { row.SMSAlert = *attrs.SMSAlert } if attrs.Checklist != nil { row.Checklist = *attrs.Checklist } if attrs.LegTime != nil { row.LegTime = strings.TrimSpace(*attrs.LegTime) } if attrs.DefaultStartTimeType != nil { row.DefaultStartTimeType = normalizeShiftTimeType(*attrs.DefaultStartTimeType) } if attrs.DefaultEndTimeType != nil { row.DefaultEndTimeType = normalizeShiftTimeType(*attrs.DefaultEndTimeType) } if attrs.UTC != nil { row.UTC = normalizeUTC(attrs.UTC) } if attrs.Dry != nil { row.Dry = *attrs.Dry } if attrs.ControlCenter != nil { row.ControlCenter = *attrs.ControlCenter } if attrs.DUL != nil { row.DUL = *attrs.DUL } if attrs.Notes != nil { row.Notes = strings.TrimSpace(*attrs.Notes) } if attrs.IsActive != nil { row.IsActive = *attrs.IsActive } } func mapBaseOperationalShiftTimesToDomain(items []BaseOperationalShiftTime) []base.BaseOperationalShiftTime { if len(items) == 0 { return nil } out := make([]base.BaseOperationalShiftTime, 0, len(items)) for i := range items { dateStart := parseBaseOperationDatePtr(items[i].DateStart) dateEnd := parseBaseOperationDatePtr(items[i].DateEnd) out = append(out, base.BaseOperationalShiftTime{ DateStart: dateStart, DateEnd: dateEnd, StartTimeType: normalizeShiftTimeType(items[i].StartTimeType), EndTimeType: normalizeShiftTimeType(items[i].EndTimeType), ShiftStart: normalizeBaseClockValue(items[i].ShiftTime.Start), ShiftEnd: normalizeBaseClockValue(items[i].ShiftTime.End), }) } return out } func MapBaseOperationalShiftTimesToDTO(items []base.BaseOperationalShiftTime) []BaseOperationalShiftTime { if len(items) == 0 { return nil } out := make([]BaseOperationalShiftTime, 0, len(items)) for i := range items { out = append(out, BaseOperationalShiftTime{ DateStart: timePtrToDateString(items[i].DateStart), DateEnd: timePtrToDateString(items[i].DateEnd), StartTimeType: normalizeShiftTimeType(items[i].StartTimeType), EndTimeType: normalizeShiftTimeType(items[i].EndTimeType), ShiftTime: BaseShiftTime{ Start: DisplayBaseShiftValue(items[i].StartTimeType, items[i].ShiftStart), End: DisplayBaseShiftValue(items[i].EndTimeType, items[i].ShiftEnd), }, }) } return out } func parseBaseOperationDate(raw string) time.Time { parsed, err := time.Parse("2006-01-02", strings.TrimSpace(raw)) if err != nil { return time.Time{} } return parsed.UTC() } func parseBaseOperationDatePtr(raw string) *time.Time { parsed := parseBaseOperationDate(raw) if parsed.IsZero() { return nil } return &parsed } func timePtrToDateString(v *time.Time) string { if v == nil || v.IsZero() { return "" } return v.UTC().Format("2006-01-02") } func normalizeBaseClockValue(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } if len(trimmed) == 5 { trimmed += ":00" } parsed, err := time.Parse("15:04:05", trimmed) if err != nil { return "" } return parsed.UTC().Format("15:04:05") } func normalizeBaseShiftDisplay(raw string) string { s := strings.TrimSpace(raw) if s == "" { return s } layouts := []string{ "15:04:05", "15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", time.RFC3339, time.RFC3339Nano, } for _, layout := range layouts { if parsed, err := time.Parse(layout, s); err == nil { return parsed.UTC().Format("15:04") } } if len(s) >= 5 && isDigitBaseClock(s[0]) && isDigitBaseClock(s[1]) && s[2] == ':' && isDigitBaseClock(s[3]) && isDigitBaseClock(s[4]) { return s[:5] } return s } // DisplayBaseShiftValue returns the user-facing representation for a shift value. // Fixed shifts are shown as HH:MM, while BMCT/ECET are shown as labels. func DisplayBaseShiftValue(timeType, raw string) string { switch normalizeShiftTimeType(timeType) { case "BMCT", "ECET": return normalizeShiftTimeType(timeType) default: return normalizeBaseShiftDisplay(raw) } } func normalizeShiftTimeType(raw string) string { switch strings.ToUpper(strings.TrimSpace(raw)) { case "", "FIXED": return "FIXED" case "BMCT": return "BMCT" case "ECET": return "ECET" default: return strings.ToUpper(strings.TrimSpace(raw)) } } func isDigitBaseClock(b byte) bool { return b >= '0' && b <= '9' } func MapBaseToResource(row *base.Base, in BaseResponseMappingInput) BaseResource { if row == nil { return BaseResource{ Type: in.ResourceType, Attributes: BaseAttributes{ BaseCategory: in.BaseCategory, DefaultStartTimeType: normalizeShiftTimeType(in.DefaultStartTimeType), DefaultEndTimeType: normalizeShiftTimeType(in.DefaultEndTimeType), ShiftTime: in.ShiftTime, OperationalShiftTimes: in.OperationalShiftTimes, Latitude: in.Latitude, Longitude: in.Longitude, }, } } return BaseResource{ Type: in.ResourceType, ID: in.ID, Attributes: BaseAttributes{ BaseName: row.BaseName, BaseAbbreviation: row.BaseAbbreviation, BaseCategory: in.BaseCategory, Address: row.Address, Latitude: row.Latitude, Longitude: row.Longitude, LandlineNumber: row.LandlineNumber, MobileNumber: row.MobileNumber, Email: row.Email, SortKey: cloneIntPtr(row.SortKey), SMSAlert: row.SMSAlert, Checklist: row.Checklist, LegTime: row.LegTime, DefaultStartTimeType: normalizeShiftTimeType(row.DefaultStartTimeType), DefaultEndTimeType: normalizeShiftTimeType(row.DefaultEndTimeType), ShiftTime: in.ShiftTime, UTC: row.UTC, Foto: in.Foto, Dry: row.Dry, ControlCenter: row.ControlCenter, DUL: row.DUL, Notes: row.Notes, HEMSEDCContactIDs: in.HEMSEDCContactIDs, HEMSEDCs: in.HEMSEDCs, MedPaxContactIDs: in.MedPaxContactIDs, MedPax: in.MedPax, ResponsiblePilotContactIDs: in.ResponsiblePilotContactIDs, ResponsiblePilots: in.ResponsiblePilots, OperationalShiftTimes: in.OperationalShiftTimes, IsActive: row.IsActive, CreatedBy: bytesToUUID(row.CreatedBy), UpdatedBy: bytesToUUID(row.UpdatedBy), CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339), DeletedAt: timePtrToString(row.DeletedAt), DeletedBy: bytesToUUID(row.DeletedBy), }, } } func stringOrDefault(v *string, d string) string { if v == nil { return d } return *v } func boolOrDefault(v *bool, d bool) bool { if v == nil { return d } return *v } func normalizeUTC(v *string) string { s := strings.TrimSpace(stringOrDefault(v, "0")) if s == "" { return "0" } return s } func floatOrDefault(v *float64, d float64) float64 { if v == nil { return d } return *v } func cloneIntPtr(v *int) *int { if v == nil { return nil } x := *v return &x } func bytesToUUID(v []byte) string { if len(v) == 0 { return "" } if name := userctx.GetDisplayName(context.TODO(), v); name != "" { return name } s, err := uuidv7.BytesToString(v) if err != nil { return "" } return s } func timePtrToString(v *time.Time) string { if v == nil { return "" } return v.UTC().Format(time.RFC3339) }