package service import ( "bytes" "context" "encoding/json" "fmt" "strings" "time" dutyroster "wucher/internal/domain/duty_roster" "wucher/internal/shared/pkg/userctx" "wucher/internal/shared/pkg/uuidv7" requestdto "wucher/internal/transport/http/dto/request" responsedto "wucher/internal/transport/http/dto/response" ) type DutyRosterService struct { repo dutyroster.Repository } type rangeAssignment struct { crew dutyroster.DutyRosterCrew start time.Time end time.Time } type DutyRosterValidationError struct { Status int Title string Pointer string Detail string } func NewDutyRosterService(repo dutyroster.Repository) *DutyRosterService { return &DutyRosterService{repo: repo} } func (s *DutyRosterService) Create(ctx context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error { normalizeDutyRosterWriteInput(row, crews) return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error { if err := txRepo.CreateHeader(ctx, row); err != nil { return err } insertRows := prepareCrewRowsForRosterInsert(row.ID, crews) for i := range insertRows { if err := txRepo.InsertCrew(ctx, &insertRows[i]); err != nil { return err } } return s.addRangeAssignments(ctx, txRepo, row, crews) }) } func (s *DutyRosterService) Update(ctx context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error { normalizeDutyRosterWriteInput(row, crews) return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error { if _, err := txRepo.LockRosterByID(ctx, row.ID); err != nil { return err } oldCrews := []dutyroster.DutyRosterCrew{} if len(crews) > 0 { var err error oldCrews, err = txRepo.ListActiveCrewsByRosterID(ctx, row.ID) if err != nil { return err } } if err := txRepo.UpdateHeader(ctx, row); err != nil { return err } if len(crews) == 0 { return nil } insertRows := prepareCrewRowsForRosterInsert(row.ID, crews) replacedKeys := map[string]struct{}{} for i := range insertRows { replaceKey := replaceByUserKey(insertRows[i].RosterID, insertRows[i].RoleCode, insertRows[i].CrewType, insertRows[i].UserID) if replaceKey != "" { if _, ok := replacedKeys[replaceKey]; !ok { if err := txRepo.SoftDeleteCrewByUser(ctx, insertRows[i].RosterID, insertRows[i].RoleCode, insertRows[i].CrewType, insertRows[i].UserID, row.UpdatedBy); err != nil { return err } replacedKeys[replaceKey] = struct{}{} } } exists, err := txRepo.CrewExists(ctx, insertRows[i]) if err != nil { return err } if exists { continue } if err := txRepo.InsertCrew(ctx, &insertRows[i]); err != nil { return err } } if err := s.addRangeAssignments(ctx, txRepo, row, crews); err != nil { return err } return s.removeRangeAssignments(ctx, txRepo, row, oldCrews, crews) }) } func (s *DutyRosterService) Delete(ctx context.Context, id []byte, deletedBy []byte) error { return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error { if _, err := txRepo.LockRosterByID(ctx, id); err != nil { return err } if err := txRepo.SoftDeleteHeader(ctx, id, deletedBy); err != nil { return err } return txRepo.SoftDeleteCrewsByRosterID(ctx, id, deletedBy) }) } func (s *DutyRosterService) HardDelete(ctx context.Context, id []byte) error { return s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error { if err := txRepo.HardDeleteCrewsByRosterID(ctx, id); err != nil { return err } return txRepo.HardDeleteHeader(ctx, id) }) } func (s *DutyRosterService) DeleteCrews(ctx context.Context, id []byte, crews []dutyroster.DutyRosterCrew, deletedBy []byte) ([]dutyroster.DeletedCrewResult, error) { if len(crews) == 0 { return []dutyroster.DeletedCrewResult{}, nil } normalizeDutyRosterFilterInput(crews) collected := make(map[string]dutyroster.DeletedCrewResult) err := s.repo.InTx(ctx, func(txRepo dutyroster.TxRepository) error { header, err := txRepo.LockRosterByID(ctx, id) if err != nil { return err } baseDate := dateOnlyUTC(header.DutyDate) for i := range crews { dates := []time.Time{baseDate} if isRoleRangeAssignable(crews[i].RoleCode) && crews[i].DateStart != nil && crews[i].DateEnd != nil { dates = expandDateRange(*crews[i].DateStart, *crews[i].DateEnd) } for _, d := range dates { rosterIDs, err := txRepo.FindRosterIDsByBaseDate(ctx, header.BaseID, d) if err != nil { return err } if len(rosterIDs) == 0 { continue } rows, err := txRepo.MatchCrewByFilter(ctx, rosterIDs, crews[i]) if err != nil { return err } if len(rows) == 0 { continue } for j := range rows { k := fmt.Sprintf("%x", rows[j].ID) if _, ok := collected[k]; !ok { collected[k] = rows[j] } } if err := txRepo.SoftDeleteCrewRows(ctx, rows, deletedBy); err != nil { return err } } } return nil }) if err != nil { return nil, err } out := make([]dutyroster.DeletedCrewResult, 0, len(collected)) for _, v := range collected { out = append(out, v) } return out, nil } func (s *DutyRosterService) GetBaseDefaultShift(ctx context.Context, baseID []byte, baseType string) (string, error) { return s.repo.GetBaseDefaultShift(ctx, baseID, baseType) } func (s *DutyRosterService) ResolveBaseDefaultShiftRange(ctx context.Context, baseID []byte, baseType string) (string, string, error) { if len(baseID) == 0 { return "", "", nil } raw, err := s.repo.GetBaseDefaultShift(ctx, baseID, baseType) if err != nil { return "", "", err } start, end := ParseDutyRosterShiftRange(raw) return start, end, nil } func (s *DutyRosterService) GetHeaderByID(ctx context.Context, id []byte) (*dutyroster.HeaderView, error) { return s.repo.GetHeaderByID(ctx, id) } func (s *DutyRosterService) GetHeaderByIDByBaseType(ctx context.Context, id []byte, baseType string) (*dutyroster.HeaderView, error) { return s.repo.GetHeaderByIDByBaseType(ctx, id, baseType) } func (s *DutyRosterService) GetCrewsByRosterIDs(ctx context.Context, rosterIDs [][]byte) ([]dutyroster.CrewView, error) { return s.repo.GetCrewsByRosterIDs(ctx, rosterIDs) } func (s *DutyRosterService) ListHeadersByRange(ctx context.Context, baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error) { return s.repo.ListHeadersByRange(ctx, baseID, from, to) } func (s *DutyRosterService) ListHeadersByRangeByBaseType(ctx context.Context, baseID []byte, from, to time.Time, baseType string) ([]dutyroster.HeaderView, error) { return s.repo.ListHeadersByRangeByBaseType(ctx, baseID, from, to, baseType) } func (s *DutyRosterService) ListHEMSBases(ctx context.Context, baseID []byte) ([]dutyroster.BaseView, error) { return s.repo.ListHEMSBases(ctx, baseID) } func (s *DutyRosterService) ListBasesByType(ctx context.Context, baseID []byte, baseType string) ([]dutyroster.BaseView, error) { return s.repo.ListBasesByType(ctx, baseID, baseType) } // ===== Write-path internals: normalization + persistence helpers ===== func normalizeDutyRosterWriteInput(row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) { for i := range crews { crews[i].ShiftStart = normalizeCrewShiftForWrite(crews[i].ShiftStart) crews[i].ShiftEnd = normalizeCrewShiftForWrite(crews[i].ShiftEnd) } } func normalizeDutyRosterFilterInput(crews []dutyroster.DutyRosterCrew) { for i := range crews { crews[i].ShiftStart = normalizeCrewShiftForFilter(crews[i].ShiftStart) crews[i].ShiftEnd = normalizeCrewShiftForFilter(crews[i].ShiftEnd) } } func normalizeRosterShiftForWrite(v string) string { s := strings.TrimSpace(v) if s == "" { return "" } if len(s) == 8 { return "2000-01-01 " + s } if len(s) == 5 { return "2000-01-01 " + s + ":00" } return s } func normalizeCrewShiftForWrite(v string) string { s := strings.TrimSpace(v) if s == "" { return "2000-01-01 00:00:00" } return normalizeRosterShiftForWrite(s) } func normalizeCrewShiftForFilter(v string) string { s := strings.TrimSpace(v) if s == "" { return "" } return normalizeRosterShiftForWrite(s) } func prepareCrewRowsForRosterInsert(rosterID []byte, crews []dutyroster.DutyRosterCrew) []dutyroster.DutyRosterCrew { if len(crews) == 0 { return nil } out := make([]dutyroster.DutyRosterCrew, 0, len(crews)) for i := range crews { row := crews[i] row.RosterID = append([]byte(nil), rosterID...) out = append(out, row) } return out } // Multi-day assignment orchestration. func (s *DutyRosterService) addRangeAssignments( ctx context.Context, txRepo dutyroster.TxRepository, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew, ) error { assignments := collectRangeAssignments(crews) if len(assignments) == 0 { return nil } currentDate := dateOnlyUTC(row.DutyDate) replacedKeys := map[string]struct{}{} for i := range assignments { dates := expandDateRange(assignments[i].start, assignments[i].end) for _, d := range dates { if d.Equal(currentDate) { continue } rosterID, err := s.ensureRosterForDate(ctx, txRepo, row, d) if err != nil { return err } insert := assignments[i].crew insert.RosterID = append([]byte(nil), rosterID...) replaceKey := replaceByUserKey(insert.RosterID, insert.RoleCode, insert.CrewType, insert.UserID) if replaceKey != "" { if _, ok := replacedKeys[replaceKey]; !ok { if err := txRepo.SoftDeleteCrewByUser(ctx, insert.RosterID, insert.RoleCode, insert.CrewType, insert.UserID, row.UpdatedBy); err != nil { return err } replacedKeys[replaceKey] = struct{}{} } } exists, err := txRepo.CrewExists(ctx, insert) if err != nil { return err } if exists { continue } if err := txRepo.InsertCrew(ctx, &insert); err != nil { return err } } } return nil } func (s *DutyRosterService) removeRangeAssignments( ctx context.Context, txRepo dutyroster.TxRepository, row *dutyroster.DutyRoster, oldCrews []dutyroster.DutyRosterCrew, newCrews []dutyroster.DutyRosterCrew, ) error { oldAssignments := collectRangeAssignments(oldCrews) if len(oldAssignments) == 0 { return nil } newAssignments := collectRangeAssignments(newCrews) newByKey := make(map[string][]rangeAssignment) for i := range newAssignments { k := assignmentKey(newAssignments[i].crew) newByKey[k] = append(newByKey[k], newAssignments[i]) } for i := range oldAssignments { k := assignmentKey(oldAssignments[i].crew) keep := map[string]struct{}{} for _, n := range newByKey[k] { for _, d := range expandDateRange(n.start, n.end) { keep[d.Format("2006-01-02")] = struct{}{} } } for _, d := range expandDateRange(oldAssignments[i].start, oldAssignments[i].end) { if _, ok := keep[d.Format("2006-01-02")]; ok { continue } if err := txRepo.SoftDeleteCrewForDate(ctx, row.BaseID, d, oldAssignments[i].crew, row.UpdatedBy); err != nil { return err } } } return nil } func (s *DutyRosterService) ensureRosterForDate( ctx context.Context, txRepo dutyroster.TxRepository, source *dutyroster.DutyRoster, dutyDate time.Time, ) ([]byte, error) { date := dateOnlyUTC(dutyDate) id, err := txRepo.FindRosterIDByBaseDate(ctx, source.BaseID, date) if err != nil { return nil, err } if len(id) > 0 { if _, err := txRepo.LockRosterByID(ctx, id); err != nil { return nil, err } return id, nil } newRow := &dutyroster.DutyRoster{ BaseID: append([]byte(nil), source.BaseID...), DutyDate: date, CreatedBy: append([]byte(nil), source.CreatedBy...), UpdatedBy: append([]byte(nil), source.UpdatedBy...), } if err := txRepo.CreateHeader(ctx, newRow); err != nil { id, fetchErr := txRepo.FindRosterIDByBaseDate(ctx, source.BaseID, date) if fetchErr == nil && len(id) > 0 { if _, lockErr := txRepo.LockRosterByID(ctx, id); lockErr != nil { return nil, lockErr } return id, nil } if fetchErr != nil { return nil, fetchErr } return nil, err } return newRow.ID, nil } func collectRangeAssignments(crews []dutyroster.DutyRosterCrew) []rangeAssignment { out := make([]rangeAssignment, 0) for i := range crews { c := crews[i] if !isRoleRangeAssignable(c.RoleCode) { continue } if c.DateStart == nil || c.DateEnd == nil { continue } start := dateOnlyUTC(*c.DateStart) end := dateOnlyUTC(*c.DateEnd) if end.Before(start) { continue } out = append(out, rangeAssignment{crew: c, start: start, end: end}) } return out } func assignmentKey(c dutyroster.DutyRosterCrew) string { return fmt.Sprintf( "%s|%x|%s|%s", strings.ToLower(strings.TrimSpace(c.RoleCode)), c.UserID, strings.ToLower(strings.TrimSpace(c.CrewType)), strings.ToLower(strings.TrimSpace(c.NameLabel)), ) } func replaceByUserKey(rosterID []byte, roleCode, crewType string, userID []byte) string { if len(rosterID) == 0 || len(userID) == 0 { return "" } return fmt.Sprintf( "%x|%s|%s|%x", rosterID, strings.ToLower(strings.TrimSpace(roleCode)), strings.ToLower(strings.TrimSpace(crewType)), userID, ) } func dateOnlyUTC(t time.Time) time.Time { return time.Date(t.UTC().Year(), t.UTC().Month(), t.UTC().Day(), 0, 0, 0, 0, time.UTC) } func expandDateRange(start, end time.Time) []time.Time { start = dateOnlyUTC(start) end = dateOnlyUTC(end) if end.Before(start) { return nil } out := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1) for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { out = append(out, d) } return out } func isRoleRangeAssignable(roleCode string) bool { switch strings.ToLower(strings.TrimSpace(roleCode)) { case "other_person", "other": return false default: return true } } // ===== Request parsing + validation helpers ===== func BuildDutyRosterCrewRows(details requestdto.DutyRosterDetailsInput, actor []byte) ([]dutyroster.DutyRosterCrew, *DutyRosterValidationError) { out := make([]dutyroster.DutyRosterCrew, 0) seen := map[string]struct{}{} appendNonPilotRole := func(roleCode string, items []requestdto.DutyRosterCrewInput) *DutyRosterValidationError { for _, in := range items { row, errObj := buildDutyRosterCrewRow( actor, roleCode, "main", in.ID, "", "", "", in.ShiftDate, true, ) if errObj != nil { return errObj } if errObj := appendDutyRosterCrewRow(&out, seen, row); errObj != nil { return errObj } } return nil } appendOtherPersonRole := func(items []requestdto.DutyRosterOtherPersonInput) *DutyRosterValidationError { for _, in := range items { if strings.TrimSpace(in.Name) == "" { return &DutyRosterValidationError{ Pointer: "/data/attributes/roster_detail/other_person", Detail: "other_person name is required", } } row := dutyroster.DutyRosterCrew{ RoleCode: "other_person", CrewType: "main", NameLabel: strings.TrimSpace(in.Name), MobilePhone: strings.TrimSpace(in.MobilePhone), Email: strings.TrimSpace(in.Email), ShiftStart: "", ShiftEnd: "", CreatedBy: actor, UpdatedBy: actor, } if errObj := appendDutyRosterCrewRow(&out, seen, row); errObj != nil { return errObj } } return nil } appendPilotRole := func(items []requestdto.DutyRosterPilotCrewInput) *DutyRosterValidationError { for _, in := range items { crewType := normalizeCrewType(in.CrewType) row, errObj := buildDutyRosterCrewRow( actor, "pilot", crewType, in.ID, "", "", "", in.ShiftDate, true, ) if errObj != nil { return errObj } if crewType == "additional" { row.FlightInstructor = in.FlightInstructor row.LineChecker = in.LineChecker row.Supervisor = in.Supervisor row.Examiner = in.Examiner row.CoPilot = in.CoPilot } if errObj := appendDutyRosterCrewRow(&out, seen, row); errObj != nil { return errObj } } return nil } if err := appendPilotRole(details.Pilot); err != nil { return nil, err } if err := appendNonPilotRole("doctor", details.Doctor); err != nil { return nil, err } if err := appendNonPilotRole("rescuer", details.Rescuer); err != nil { return nil, err } if err := appendNonPilotRole("helper", details.Helper); err != nil { return nil, err } if err := appendOtherPersonRole(details.OtherPerson); err != nil { return nil, err } return out, nil } func buildDutyRosterCrewRow( actor []byte, roleCode string, crewType string, id string, name string, mobilePhone string, email string, shiftDate *requestdto.DutyRosterShiftDate, allowUserID bool, ) (dutyroster.DutyRosterCrew, *DutyRosterValidationError) { row := dutyroster.DutyRosterCrew{ RoleCode: roleCode, CrewType: crewType, NameLabel: strings.TrimSpace(name), MobilePhone: strings.TrimSpace(mobilePhone), Email: strings.TrimSpace(email), ShiftStart: "", ShiftEnd: "", CreatedBy: actor, UpdatedBy: actor, } if allowUserID && strings.TrimSpace(id) != "" { parsedID, err := uuidv7.ParseString(strings.TrimSpace(id)) if err != nil { return dutyroster.DutyRosterCrew{}, &DutyRosterValidationError{ Pointer: "/data/attributes/roster_detail", Detail: "crew id is invalid UUID", } } row.UserID = parsedID } if errObj := applyCrewShiftDateInput(&row, shiftDate); errObj != nil { return dutyroster.DutyRosterCrew{}, errObj } return row, nil } func appendDutyRosterCrewRow(out *[]dutyroster.DutyRosterCrew, seen map[string]struct{}, row dutyroster.DutyRosterCrew) *DutyRosterValidationError { dupKey := buildDutyRosterCrewDupKey(row) if _, ok := seen[dupKey]; ok { return &DutyRosterValidationError{ Pointer: "/data/attributes/roster_detail", Detail: "duplicate crew assignment in request payload", } } seen[dupKey] = struct{}{} *out = append(*out, row) return nil } func validateDutyRosterDeleteCrews(crews []dutyroster.DutyRosterCrew) *DutyRosterValidationError { for i := range crews { if !roleNeedsShiftDateForDelete(crews[i].RoleCode) { continue } hasStart := crews[i].DateStart != nil hasEnd := crews[i].DateEnd != nil if hasStart != hasEnd { return &DutyRosterValidationError{ Pointer: "/data/attributes/roster_detail", Detail: "shift_date.date_start and shift_date.date_end must be provided together", } } if hasStart && crews[i].DateEnd.Before(*crews[i].DateStart) { return &DutyRosterValidationError{ Pointer: "/data/attributes/roster_detail", Detail: "shift_date.date_end must be >= shift_date.date_start", } } } return nil } func applyCrewShiftDateInput(row *dutyroster.DutyRosterCrew, in *requestdto.DutyRosterShiftDate) *DutyRosterValidationError { if in == nil { return nil } if strings.TrimSpace(in.DateStart) != "" { d, err := parseDateOnlyForCrew(in.DateStart) if err != nil { return &DutyRosterValidationError{ Pointer: "/data/attributes/roster_detail", Detail: "ERR_DUTY_ROSTER_SHIFT_DATE_START_FORMAT", } } row.DateStart = &d } if strings.TrimSpace(in.DateEnd) != "" { d, err := parseDateOnlyForCrew(in.DateEnd) if err != nil { return &DutyRosterValidationError{ Pointer: "/data/attributes/roster_detail", Detail: "ERR_DUTY_ROSTER_SHIFT_DATE_END_FORMAT", } } row.DateEnd = &d } return nil } func buildDutyRosterCrewDupKey(row dutyroster.DutyRosterCrew) string { return strings.ToLower(strings.TrimSpace(row.RoleCode)) + "|" + strings.ToLower(strings.TrimSpace(row.CrewType)) + "|" + uuidNoCtx(row.UserID) + "|" + strings.ToLower(strings.TrimSpace(row.NameLabel)) + "|" + dateToString(row.DateStart) + "|" + dateToString(row.DateEnd) + "|" + strings.TrimSpace(row.ShiftStart) + "|" + strings.TrimSpace(row.ShiftEnd) } func normalizeClockOrDefaultForCrew(v, def string) string { s := strings.TrimSpace(v) if s == "" { return def } if len(s) == 5 { return s + ":00" } return s } func normalizeClockInputForCrew(v, def, pointer string) (string, *DutyRosterValidationError) { out := normalizeClockOrDefaultForCrew(v, def) if strings.TrimSpace(out) == "" { return out, nil } if _, err := time.Parse("15:04:05", out); err != nil { return "", &DutyRosterValidationError{ Pointer: pointer, Detail: "time must be HH:MM or HH:MM:SS", } } return out, nil } func NormalizeDutyRosterClockInput(v, def, pointer string) (string, *DutyRosterValidationError) { return normalizeClockInputForCrew(v, def, pointer) } // ===== Date/time parsing helpers used by handlers ===== func ParseDutyRosterShiftRange(raw string) (string, string) { tokens := extractClockTokensForShift(raw) if len(tokens) == 0 { return "", "" } start := normalizeClockOrDefaultForCrew(tokens[0], "") if _, err := time.Parse("15:04:05", start); err != nil { start = "" } end := start if len(tokens) > 1 { end = normalizeClockOrDefaultForCrew(tokens[1], "") if _, err := time.Parse("15:04:05", end); err != nil { end = "" } } return start, end } func ResolveDutyRosterShiftDefaultsForUpdate(ctx context.Context, svc dutyroster.Service, baseID []byte, baseType, currentShiftStart, currentShiftEnd string) (string, string, error) { defaultShiftStart := strings.TrimSpace(currentShiftStart) defaultShiftEnd := strings.TrimSpace(currentShiftEnd) if defaultShiftStart != "" && defaultShiftEnd != "" { return defaultShiftStart, defaultShiftEnd, nil } baseShiftStart, baseShiftEnd, err := svc.ResolveBaseDefaultShiftRange(ctx, baseID, baseType) if err != nil { return "", "", err } if defaultShiftStart == "" { defaultShiftStart = baseShiftStart } if defaultShiftEnd == "" { defaultShiftEnd = baseShiftEnd } return defaultShiftStart, defaultShiftEnd, nil } func extractClockTokensForShift(raw string) []string { s := strings.TrimSpace(raw) if s == "" { return nil } out := make([]string, 0, 2) for i := 0; i < len(s) && len(out) < 2; i++ { if i+5 > len(s) || !isDigitForShift(s[i]) || !isDigitForShift(s[i+1]) || s[i+2] != ':' || !isDigitForShift(s[i+3]) || !isDigitForShift(s[i+4]) { continue } token := s[i : i+5] if i+8 <= len(s) && s[i+5] == ':' && isDigitForShift(s[i+6]) && isDigitForShift(s[i+7]) { token = s[i : i+8] } out = append(out, token) } return out } func isDigitForShift(b byte) bool { return b >= '0' && b <= '9' } func ParseDutyRosterDateRangeOrDefault(fromRaw, toRaw string, now time.Time) (time.Time, time.Time, *DutyRosterValidationError) { defaultFrom := time.Date(now.UTC().Year(), now.UTC().Month(), 1, 0, 0, 0, 0, time.UTC) defaultTo := defaultFrom.AddDate(0, 1, -1) fromText := strings.TrimSpace(fromRaw) toText := strings.TrimSpace(toRaw) from := defaultFrom to := defaultTo if fromText != "" { parsedFrom, err := parseDateOnlyForCrew(fromText) if err != nil { return time.Time{}, time.Time{}, &DutyRosterValidationError{ Pointer: "/query/from", Detail: "from must be YYYY-MM-DD", } } from = parsedFrom } if toText != "" { parsedTo, err := parseDateOnlyForCrew(toText) if err != nil { return time.Time{}, time.Time{}, &DutyRosterValidationError{ Pointer: "/query/to", Detail: "to must be YYYY-MM-DD", } } to = parsedTo } return from, to, nil } func ParseDutyRosterDateOnly(v string) (time.Time, error) { return parseDateOnlyForCrew(v) } // ===== Request body decoder helpers ===== func BuildDutyRosterDeleteCrews(raw []byte, actor []byte) ([]dutyroster.DutyRosterCrew, *DutyRosterValidationError) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("{}")) { return nil, nil } var req struct { Data struct { Attributes struct { Detail *requestdto.DutyRosterDetailsInput `json:"roster_detail,omitempty"` } `json:"attributes"` } `json:"data"` } if err := json.Unmarshal(trimmed, &req); err != nil { return nil, &DutyRosterValidationError{ Pointer: "/data", Detail: "invalid delete payload", } } detail := req.Data.Attributes.Detail if detail == nil { return nil, nil } crews, crewErr := BuildDutyRosterCrewRows(*detail, actor) if crewErr != nil { return nil, crewErr } if errObj := validateDutyRosterDeleteCrews(crews); errObj != nil { return nil, errObj } return crews, nil } func isDutyRosterDetailsInputEmpty(d requestdto.DutyRosterDetailsInput) bool { return len(d.Pilot) == 0 && len(d.Doctor) == 0 && len(d.Rescuer) == 0 && len(d.Helper) == 0 && len(d.OtherPerson) == 0 } func parseDateOnlyForCrew(v string) (time.Time, error) { return time.ParseInLocation("2006-01-02", strings.TrimSpace(v), time.UTC) } func normalizeCrewType(v string) string { s := strings.ToLower(strings.TrimSpace(v)) if s == "additional" { return "additional" } return "main" } func roleNeedsShiftDateForDelete(roleCode string) bool { switch strings.ToLower(strings.TrimSpace(roleCode)) { case "other_person", "other": return false default: return true } } func dateToString(t *time.Time) string { if t == nil || t.IsZero() { return "" } return t.UTC().Format("2006-01-02") } // uuidNoCtx converts a UUID byte slice to string without any context-based lookup. // Used only for internal deduplication keys where user names are not needed. func uuidNoCtx(id []byte) string { if len(id) == 0 { return "" } s, err := uuidv7.BytesToString(id) if err != nil { return "" } return s } func uuidStringOrEmpty(ctx context.Context, id []byte) string { if len(id) == 0 { return "" } // Try GlobalUserNameGetter first. if name := getLastNameFromServiceGetter(ctx, id); name != "" { return name } return uuidNoCtx(id) } func getLastNameFromServiceGetter(ctx context.Context, id []byte) string { if name := userctx.GetDisplayName(ctx, id); name != "" { return name } return "" } // ===== DTO/resource mapping helpers ===== func DutyRosterResource(ctx context.Context, header dutyroster.HeaderView, crews []dutyroster.CrewView) responsedto.DutyRosterResource { details := responsedto.DutyRosterDetailsResponse{ Pilot: []responsedto.DutyRosterPilotCrew{}, Doctor: []responsedto.DutyRosterCrew{}, Rescuer: []responsedto.DutyRosterCrew{}, Helper: []responsedto.DutyRosterCrew{}, OtherPerson: []responsedto.DutyRosterCrew{}, } for i := range crews { baseItem := responsedto.DutyRosterCrew{ ID: firstNonEmpty(uuidNoCtx(crews[i].UserID), uuidNoCtx(crews[i].ID)), Name: strings.TrimSpace(crews[i].Name), Role: strings.TrimSpace(crews[i].Role), MobilePhone: strings.TrimSpace(crews[i].MobilePhone), Email: strings.TrimSpace(crews[i].Email), } if crews[i].DateStart != nil || crews[i].DateEnd != nil { baseItem.ShiftDate = &responsedto.DutyRosterShiftDate{DateStart: dateToString(crews[i].DateStart), DateEnd: dateToString(crews[i].DateEnd)} } switch roleBucket(crews[i].RoleCode) { case "pilot": applyEffectiveCrewShiftTime(&baseItem, crews[i], header) pilotItem := responsedto.DutyRosterPilotCrew{ ID: baseItem.ID, Name: baseItem.Name, Role: baseItem.Role, CrewType: strings.TrimSpace(crews[i].CrewType), MobilePhone: baseItem.MobilePhone, Email: baseItem.Email, ShiftDate: baseItem.ShiftDate, } if strings.EqualFold(strings.TrimSpace(crews[i].CrewType), "additional") { pilotItem.FlightInstructor = boolPtrIfTrue(crews[i].FlightInstructor) pilotItem.LineChecker = boolPtrIfTrue(crews[i].LineChecker) pilotItem.Supervisor = boolPtrIfTrue(crews[i].Supervisor) pilotItem.Examiner = boolPtrIfTrue(crews[i].Examiner) pilotItem.CoPilot = boolPtrIfTrue(crews[i].CoPilot) } details.Pilot = append(details.Pilot, pilotItem) case "doctor": applyEffectiveCrewShiftTime(&baseItem, crews[i], header) details.Doctor = append(details.Doctor, baseItem) case "rescuer": applyEffectiveCrewShiftTime(&baseItem, crews[i], header) details.Rescuer = append(details.Rescuer, baseItem) case "helper": applyEffectiveCrewShiftTime(&baseItem, crews[i], header) details.Helper = append(details.Helper, baseItem) default: baseItem.ShiftDate = nil baseItem.ShiftTime = nil details.OtherPerson = append(details.OtherPerson, baseItem) } } return responsedto.DutyRosterResource{ Type: "duty_roster", ID: uuidNoCtx(header.ID), Attributes: responsedto.DutyRosterAttributes{ Bases: responsedto.DutyRosterBaseInfo{ ID: uuidNoCtx(header.BaseID), Type: "hems", BaseName: header.BaseName, BaseAbbreviation: header.BaseAbbreviation, }, Helicopter: buildDutyRosterHelicopterFromHeader(header), DutyDate: header.DutyDate.UTC().Format("2006-01-02"), ShiftTime: responsedto.DutyRosterShiftTime{ShiftStart: header.ShiftStart, ShiftEnd: header.ShiftEnd}, Detail: details, CreatedBy: uuidStringOrEmpty(ctx, header.CreatedBy), CreatedAt: header.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: header.UpdatedAt.UTC().Format(time.RFC3339), }, } } func applyEffectiveCrewShiftTime(baseItem *responsedto.DutyRosterCrew, crew dutyroster.CrewView, header dutyroster.HeaderView) { effectiveShiftStart := strings.TrimSpace(crew.ShiftStart) effectiveShiftEnd := strings.TrimSpace(crew.ShiftEnd) if effectiveShiftStart == "" { effectiveShiftStart = header.ShiftStart } if effectiveShiftEnd == "" { effectiveShiftEnd = header.ShiftEnd } if effectiveShiftStart == "" && effectiveShiftEnd == "" { return } baseItem.ShiftTime = &responsedto.DutyRosterShiftTime{ShiftStart: effectiveShiftStart, ShiftEnd: effectiveShiftEnd} } func buildDutyRosterHelicopterFromHeader(header dutyroster.HeaderView) responsedto.DutyRosterHelicopterSummary { id := uuidNoCtx(header.HelicopterID) typ := strings.TrimSpace(header.HelicopterType) designation := strings.TrimSpace(header.HelicopterDesignation) identifier := strings.TrimSpace(header.HelicopterIdentifier) return responsedto.DutyRosterHelicopterSummary{ ID: id, Designation: designation, Identifier: identifier, Type: typ, } } func DutyRosterDeletedCrewItems(rows []dutyroster.DeletedCrewResult) []map[string]any { out := make([]map[string]any, 0, len(rows)) for i := range rows { out = append(out, map[string]any{ "id": uuidNoCtx(rows[i].ID), "roster_id": uuidNoCtx(rows[i].RosterID), "user_id": uuidNoCtx(rows[i].UserID), "name": strings.TrimSpace(rows[i].Name), "role_code": strings.TrimSpace(rows[i].RoleCode), "crew_type": strings.TrimSpace(rows[i].CrewType), "date_start": dateToString(rows[i].DateStart), "date_end": dateToString(rows[i].DateEnd), }) } return out } func DutyRosterCrewAuditMetadata(rosterID []byte, baseType string, crews []dutyroster.DutyRosterCrew) map[string]any { items := make([]map[string]any, 0, len(crews)) for i := range crews { items = append(items, map[string]any{ "user_id": uuidNoCtx(crews[i].UserID), "name_label": strings.TrimSpace(crews[i].NameLabel), "role_code": strings.TrimSpace(crews[i].RoleCode), "crew_type": strings.TrimSpace(crews[i].CrewType), "date_start": dateToString(crews[i].DateStart), "date_end": dateToString(crews[i].DateEnd), }) } return map[string]any{ "roster_id": uuidNoCtx(rosterID), "base_type": strings.TrimSpace(baseType), "crew_count": len(crews), "crew_detail": items, } } // ===== Grouping/filtering/pagination helpers for list endpoints ===== func GroupRostersByBase(items []responsedto.DutyRosterResource) []responsedto.DutyRosterBaseGroupResource { return groupRosterResourcesByBase( items, func(item responsedto.DutyRosterResource) string { return item.Attributes.Bases.ID }, func(item responsedto.DutyRosterResource) responsedto.DutyRosterBaseGroupResource { return responsedto.DutyRosterBaseGroupResource{ Type: "hems_base_roster_group", Attributes: responsedto.DutyRosterBaseGroupAttributes{ Bases: item.Attributes.Bases, Rosters: []responsedto.DutyRosterGroupRoster{}, }, } }, func(group *responsedto.DutyRosterBaseGroupResource, item responsedto.DutyRosterResource) { group.Attributes.Rosters = append(group.Attributes.Rosters, responsedto.DutyRosterGroupRoster{ ID: item.ID, FlightID: item.Attributes.FlightID, Helicopter: item.Attributes.Helicopter, DutyDate: item.Attributes.DutyDate, ShiftTime: item.Attributes.ShiftTime, Detail: item.Attributes.Detail, CreatedBy: item.Attributes.CreatedBy, CreatedAt: item.Attributes.CreatedAt, UpdatedAt: item.Attributes.UpdatedAt, }) }, ) } func groupRosterResourcesByBase[T any, G any]( items []T, getBaseID func(item T) string, newGroup func(item T) G, appendToGroup func(group *G, item T), ) []G { out := make([]G, 0) indexByBase := map[string]int{} for i := range items { baseKey := normalizeRosterBaseGroupKey(getBaseID(items[i])) idx, ok := indexByBase[baseKey] if !ok { out = append(out, newGroup(items[i])) idx = len(out) - 1 indexByBase[baseKey] = idx } appendToGroup(&out[idx], items[i]) } return out } func normalizeRosterBaseGroupKey(baseID string) string { s := strings.TrimSpace(baseID) if s == "" { return "unknown" } return s } func MergeEmptyBaseGroups(groups []responsedto.DutyRosterBaseGroupResource, bases []dutyroster.BaseView) []responsedto.DutyRosterBaseGroupResource { indexByBase := make(map[string]int, len(groups)) for i := range groups { baseID := strings.TrimSpace(groups[i].Attributes.Bases.ID) if baseID == "" || baseID == "unknown" { continue } indexByBase[baseID] = i } for i := range bases { baseID := uuidNoCtx(bases[i].ID) if baseID == "" { continue } if _, exists := indexByBase[baseID]; exists { continue } groups = append(groups, responsedto.DutyRosterBaseGroupResource{ Type: "hems_base_roster_group", Attributes: responsedto.DutyRosterBaseGroupAttributes{ Bases: responsedto.DutyRosterBaseInfo{ ID: baseID, Type: "hems", BaseName: strings.TrimSpace(bases[i].BaseName), BaseAbbreviation: strings.TrimSpace(bases[i].BaseAbbreviation), }, Rosters: []responsedto.DutyRosterGroupRoster{}, }, }) } return groups } func LoadHEMSRosterGroups(ctx context.Context, svc dutyroster.Service, baseID []byte, from, to time.Time, baseName string) ([]responsedto.DutyRosterBaseGroupResource, error) { headers, err := svc.ListHeadersByRange(ctx, baseID, from, to) if err != nil { return nil, err } bases, err := svc.ListHEMSBases(ctx, baseID) if err != nil { return nil, err } rosterIDs := make([][]byte, 0, len(headers)) for i := range headers { rosterIDs = append(rosterIDs, headers[i].ID) } crews, err := svc.GetCrewsByRosterIDs(ctx, rosterIDs) if err != nil { return nil, err } byRoster := make(map[string][]dutyroster.CrewView) for i := range crews { k, _ := uuidv7.BytesToString(crews[i].RosterID) byRoster[k] = append(byRoster[k], crews[i]) } out := make([]responsedto.DutyRosterResource, 0, len(headers)) for i := range headers { k, _ := uuidv7.BytesToString(headers[i].ID) out = append(out, DutyRosterResource(ctx, headers[i], byRoster[k])) } grouped := MergeEmptyBaseGroups(GroupRostersByBase(out), bases) needle := strings.ToLower(strings.TrimSpace(baseName)) if needle == "" { return grouped, nil } filtered := make([]responsedto.DutyRosterBaseGroupResource, 0, len(grouped)) for i := range grouped { name := strings.ToLower(strings.TrimSpace(grouped[i].Attributes.Bases.BaseName)) if strings.Contains(name, needle) { filtered = append(filtered, grouped[i]) } } return filtered, nil } func PaginateHEMSRosterGroups(groups []responsedto.DutyRosterBaseGroupResource, start, length int) ([]responsedto.DutyRosterBaseGroupResource, int) { total := len(groups) if start > total { start = total } end := start + length if end > total { end = total } return groups[start:end], total } // ===== Small generic helpers ===== func roleBucket(code string) string { s := strings.ToLower(strings.TrimSpace(code)) switch s { case "pilot": return "pilot" case "doctor": return "doctor" case "air_rescuer", "rescuer": return "rescuer" case "flight_assistant", "helper": return "helper" case "other", "other_person": return "other_person" default: return "other_person" } } func firstNonEmpty(a, b string) string { if strings.TrimSpace(a) != "" { return a } return b } func boolPtrIfTrue(v bool) *bool { if !v { return nil } b := true return &b }