package mysql import ( "context" "database/sql/driver" "errors" "fmt" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "wucher/internal/domain/base" "wucher/internal/shared/pkg/sortkey" "wucher/internal/shared/pkg/uuidv7" ) type BaseRepository struct { db *gorm.DB } type binaryValue struct { data []byte } func (b binaryValue) Value() (driver.Value, error) { if len(b.data) == 0 { return nil, nil } return append([]byte(nil), b.data...), nil } func NewBaseRepository(db *gorm.DB) *BaseRepository { return &BaseRepository{db: db} } func (r *BaseRepository) CreateBase(ctx context.Context, row *base.Base, categoryType string) error { categoryID, err := r.categoryIDByType(ctx, categoryType) if err != nil { return err } row.BaseCategoryID = categoryID if actor := actorUserIDFromContext(ctx); len(actor) > 0 { row.CreatedBy = actor row.UpdatedBy = actor } row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType) row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType) row.DefaultShiftStart = baseClockStorageValue(row.DefaultShiftStart) row.DefaultShiftEnd = baseClockStorageValue(row.DefaultShiftEnd) requestedIsActive := row.IsActive operationalShiftTimes := row.OperationalShiftTimes row.OperationalShiftTimes = nil db := r.db.WithContext(ctx) return db.Transaction(func(tx *gorm.DB) error { if err := tx.Omit(clause.Associations).Create(row).Error; err != nil { return err } if err := tx.Model(&base.Base{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error; err != nil { return err } if err := r.replaceBaseContactRolesTx(ctx, tx, row.ID, row.HEMSEDCContactIDs, row.MedPaxContactIDs, row.ResponsiblePilotContactIDs); err != nil { return err } return r.replaceBaseOperationalShiftTimesTx(ctx, tx, row.ID, operationalShiftTimes) }) } func (r *BaseRepository) UpdateBase(ctx context.Context, row *base.Base, categoryType string) error { categoryID, err := r.categoryIDByType(ctx, categoryType) if err != nil { return err } row.BaseCategoryID = categoryID if actor := actorUserIDFromContext(ctx); len(actor) > 0 { row.UpdatedBy = actor } row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType) row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType) row.DefaultShiftStart = baseClockStorageValue(row.DefaultShiftStart) row.DefaultShiftEnd = baseClockStorageValue(row.DefaultShiftEnd) return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Use an explicit UPDATE statement so the base shift columns are stored // in the same datetime shape that exists in the local schema while still // accepting clock-only inputs from the API layer. if err := tx.Exec( fmt.Sprintf(`UPDATE bases SET base = ?, base_category_id = ?, foto_attachment_id = ?, base_abbreviation = ?, address = ?, latitude = ?, longitude = ?, landline_number = ?, mobile_number = ?, email = ?, sortkey = ?, sms_alert = ?, checklist = ?, leg_time = ?, default_start_time_type = ?, default_end_time_type = ?, default_shift_start = ?, default_shift_end = ?, utc = ?, dry = ?, control_center = ?, dul = ?, notes = ?, is_active = ?, updated_by = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`), row.BaseName, binaryValue{data: row.BaseCategoryID}, binaryValue{data: row.FotoAttachmentID}, row.BaseAbbreviation, row.Address, row.Latitude, row.Longitude, row.LandlineNumber, row.MobileNumber, row.Email, row.SortKey, row.SMSAlert, row.Checklist, row.LegTime, row.DefaultStartTimeType, row.DefaultEndTimeType, row.DefaultShiftStart, row.DefaultShiftEnd, row.UTC, row.Dry, row.ControlCenter, row.DUL, row.Notes, row.IsActive, binaryValue{data: row.UpdatedBy}, time.Now().UTC(), binaryValue{data: row.ID}, ).Error; err != nil { return err } if err := r.replaceBaseContactRolesTx(ctx, tx, row.ID, row.HEMSEDCContactIDs, row.MedPaxContactIDs, row.ResponsiblePilotContactIDs); err != nil { return err } if row.OperationalShiftTimes == nil { return nil } return r.replaceBaseOperationalShiftTimesTx(ctx, tx, row.ID, row.OperationalShiftTimes) }) } func (r *BaseRepository) DeleteBase(ctx context.Context, id []byte, categoryType string) error { categoryID, err := r.categoryIDByType(ctx, categoryType) if err != nil { return err } if err := ensureNoReferenceBeforeDelete(ctx, r.db, "bases", id); err != nil { return err } now := time.Now().UTC() updates := map[string]any{ "deleted_at": now, } if actor := actorUserIDFromContext(ctx); len(actor) > 0 { updates["deleted_by"] = actor updates["updated_by"] = actor } return mapDeleteConstraintError(r.db.WithContext(ctx). Model(&base.Base{}). Where("id = ? AND base_category_id = ? AND deleted_at IS NULL", id, categoryID). Updates(updates).Error) } func (r *BaseRepository) GetBaseByID(ctx context.Context, id []byte, categoryType string) (*base.Base, error) { categoryID, err := r.categoryIDByType(ctx, categoryType) if err != nil { return nil, err } var row base.Base err = r.db.WithContext(ctx). Preload("FotoAttachment"). Preload("FotoAttachment.File"). Preload("OperationalShiftTimes", preloadBaseOperationalShiftTimes). Where("id = ? AND base_category_id = ? AND deleted_at IS NULL", id, categoryID). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err == nil { if loadErr := r.loadBaseContactRoles(ctx, []*base.Base{&row}); loadErr != nil { return nil, loadErr } } return &row, err } func (r *BaseRepository) ListBases(ctx context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]base.Base, int64, error) { var rows []base.Base var total int64 baseQuery := r.db.WithContext(ctx). Model(&base.Base{}). Where("deleted_at IS NULL") if strings.TrimSpace(categoryType) != "" { categoryID, err := r.categoryIDByType(ctx, categoryType) if err != nil { return nil, 0, err } baseQuery = baseQuery.Where("base_category_id = ?", categoryID) } if filter != "" { like := "%" + filter + "%" baseQuery = baseQuery.Where( "base LIKE ? OR base_abbreviation LIKE ? OR address LIKE ? OR email LIKE ? OR landline_number LIKE ? OR mobile_number LIKE ?", like, like, like, like, like, like, ) } if dul != nil { baseQuery = baseQuery.Where("dul = ?", *dul) } query := baseQuery if sort != "" { query = query.Order(sort) } else { for _, clause := range sortkey.ActivePositiveSortClauses("bases", "is_active", "sortkey", "base", false) { query = query.Order(clause) } } if err := baseQuery.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } query = query.Preload("BaseCategory") query = query.Preload("FotoAttachment").Preload("FotoAttachment.File") query = query.Preload("OperationalShiftTimes", preloadBaseOperationalShiftTimes) if err := query.Find(&rows).Error; err != nil { return nil, 0, err } ptrs := make([]*base.Base, 0, len(rows)) for i := range rows { ptrs = append(ptrs, &rows[i]) } if err := r.loadBaseContactRoles(ctx, ptrs); err != nil { return nil, 0, err } return rows, total, nil } func preloadBaseOperationalShiftTimes(db *gorm.DB) *gorm.DB { if db == nil { return db } q := db.Where("deleted_at IS NULL").Order("date_start ASC, date_end ASC") if db.Dialector != nil && strings.EqualFold(db.Dialector.Name(), "mysql") { q = q.Select(` id, base_id, date_start, date_end, start_time_type, end_time_type, CASE WHEN shift_start IS NULL THEN '' ELSE TIME_FORMAT(shift_start, '%H:%i:%s') END AS shift_start, CASE WHEN shift_end IS NULL THEN '' ELSE TIME_FORMAT(shift_end, '%H:%i:%s') END AS shift_end, created_at, created_by, updated_at, updated_by, deleted_at, deleted_by `) } return q } func (r *BaseRepository) replaceBaseContactRolesTx( ctx context.Context, tx *gorm.DB, baseID []byte, hemsEDCIDs [][]byte, medPaxIDs [][]byte, pilotIDs [][]byte, ) error { if len(baseID) != 16 { return errors.New("invalid base id") } all := append(copyIDs(hemsEDCIDs), medPaxIDs...) all = append(all, pilotIDs...) if err := ensureContactsExistTx(tx, all); err != nil { return err } if err := tx.Where("base_id = ?", baseID).Delete(&base.BaseContactRole{}).Error; err != nil { return err } rows := make([]base.BaseContactRole, 0, len(hemsEDCIDs)+len(medPaxIDs)+len(pilotIDs)) actor := actorUserIDFromContext(ctx) for _, contactID := range hemsEDCIDs { if len(contactID) != 16 { return fmt.Errorf("invalid hems_edc contact id") } rows = append(rows, base.BaseContactRole{ BaseID: baseID, ContactID: contactID, RoleCode: base.BaseContactRoleHEMSEDC, CreatedBy: actor, UpdatedBy: actor, }) } for _, contactID := range medPaxIDs { if len(contactID) != 16 { return fmt.Errorf("invalid med_pax contact id") } rows = append(rows, base.BaseContactRole{ BaseID: baseID, ContactID: contactID, RoleCode: base.BaseContactRoleMedPax, CreatedBy: actor, UpdatedBy: actor, }) } for _, contactID := range pilotIDs { if len(contactID) != 16 { return fmt.Errorf("invalid responsible_pilot contact id") } rows = append(rows, base.BaseContactRole{ BaseID: baseID, ContactID: contactID, RoleCode: base.BaseContactRoleResponsiblePilot, CreatedBy: actor, UpdatedBy: actor, }) } if len(rows) == 0 { return nil } return tx.Create(&rows).Error } func (r *BaseRepository) replaceBaseOperationalShiftTimesTx(ctx context.Context, tx *gorm.DB, baseID []byte, items []base.BaseOperationalShiftTime) error { if len(baseID) != 16 { return errors.New("invalid base id") } if items == nil { return nil } actor := actorUserIDFromContext(ctx) if err := tx.Where("base_id = ?", baseID).Delete(&base.BaseOperationalShiftTime{}).Error; err != nil { return err } if len(items) == 0 { return nil } now := time.Now().UTC() for i := range items { row := items[i] if row.DateStart == nil || row.DateEnd == nil || row.DateStart.IsZero() || row.DateEnd.IsZero() { return errors.New("invalid operational shift date range") } if row.DateEnd.Before(*row.DateStart) { return errors.New("operational shift date_end must be >= date_start") } if len(row.ID) == 0 { row.ID = uuidv7.MustBytes() } dateStart := row.DateStart.UTC().Format("2006-01-02") dateEnd := row.DateEnd.UTC().Format("2006-01-02") var createdBy any var updatedBy any if len(actor) > 0 { createdBy = binaryValue{data: actor} updatedBy = binaryValue{data: actor} } shiftStart := shiftTimeValue(row.ShiftStart) shiftEnd := shiftTimeValue(row.ShiftEnd) if err := tx.Exec( `INSERT INTO base_operational_shift_times (id, base_id, date_start, date_end, start_time_type, end_time_type, shift_start, shift_end, created_at, created_by, updated_at, updated_by, deleted_at, deleted_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, binaryValue{data: row.ID}, binaryValue{data: baseID}, dateStart, dateEnd, row.StartTimeType, row.EndTimeType, shiftStart, shiftEnd, now, createdBy, now, updatedBy, nil, nil, ).Error; err != nil { return err } } return nil } func shiftTimeValue(raw string) any { clock, ok := extractClockValue(raw) if !ok { return nil } return clock } func baseClockStorageValue(raw string) string { clock, ok := extractClockValue(raw) if !ok { return strings.TrimSpace(raw) } return "2000-01-01 " + clock } func normalizeBaseShiftTimeType(raw string) string { switch strings.ToUpper(strings.TrimSpace(raw)) { case "", base.ShiftTimeTypeFixed: return base.ShiftTimeTypeFixed case base.ShiftTimeTypeBMCT: return base.ShiftTimeTypeBMCT case base.ShiftTimeTypeECET: return base.ShiftTimeTypeECET default: return strings.ToUpper(strings.TrimSpace(raw)) } } func extractClockValue(raw string) (string, bool) { s := strings.TrimSpace(raw) if s == "" { return "", false } 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:05"), true } } return "", false } func ensureContactsExistTx(tx *gorm.DB, ids [][]byte) error { valid := make([][]byte, 0, len(ids)) seen := map[string]struct{}{} for _, id := range ids { if len(id) != 16 { return errors.New("invalid contact id") } key := string(id) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} valid = append(valid, id) } if len(valid) == 0 { return nil } var count int64 if err := tx.Table("users").Where("id IN ?", valid).Count(&count).Error; err != nil { return err } if count != int64(len(valid)) { return errors.New("one or more contact ids not found") } return nil } func (r *BaseRepository) loadBaseContactRoles(ctx context.Context, bases []*base.Base) error { if len(bases) == 0 { return nil } baseIDs := make([][]byte, 0, len(bases)) index := make(map[string]*base.Base, len(bases)) for _, b := range bases { if b == nil || len(b.ID) != 16 { continue } baseIDs = append(baseIDs, b.ID) index[string(b.ID)] = b b.HEMSEDCContactIDs = nil b.MedPaxContactIDs = nil b.ResponsiblePilotContactIDs = nil b.HEMSEDCs = nil b.MedPax = nil b.ResponsiblePilots = nil } if len(baseIDs) == 0 { return nil } var rels []base.BaseContactRole if err := r.db.WithContext(ctx). Where("base_id IN ?", baseIDs). Order("created_at ASC"). Find(&rels).Error; err != nil { return err } for i := range rels { b := index[string(rels[i].BaseID)] if b == nil { continue } switch rels[i].RoleCode { case base.BaseContactRoleHEMSEDC: b.HEMSEDCContactIDs = append(b.HEMSEDCContactIDs, append([]byte(nil), rels[i].ContactID...)) case base.BaseContactRoleMedPax: b.MedPaxContactIDs = append(b.MedPaxContactIDs, append([]byte(nil), rels[i].ContactID...)) case base.BaseContactRoleResponsiblePilot: b.ResponsiblePilotContactIDs = append(b.ResponsiblePilotContactIDs, append([]byte(nil), rels[i].ContactID...)) } } var hemsEDCContactIDs [][]byte var medPaxContactIDs [][]byte var pilotContactIDs [][]byte for _, b := range bases { for i := range b.HEMSEDCContactIDs { hemsEDCContactIDs = append(hemsEDCContactIDs, b.HEMSEDCContactIDs[i]) } for i := range b.MedPaxContactIDs { medPaxContactIDs = append(medPaxContactIDs, b.MedPaxContactIDs[i]) } for i := range b.ResponsiblePilotContactIDs { pilotContactIDs = append(pilotContactIDs, b.ResponsiblePilotContactIDs[i]) } } allContactIDs := append(append(hemsEDCContactIDs, medPaxContactIDs...), pilotContactIDs...) contactMap, err := r.contactNameMap(ctx, allContactIDs) if err != nil { return err } for _, b := range bases { for i := range b.HEMSEDCContactIDs { id := b.HEMSEDCContactIDs[i] p := contactMap[string(id)] p.ContactID = append([]byte(nil), id...) b.HEMSEDCs = append(b.HEMSEDCs, p) } for i := range b.MedPaxContactIDs { id := b.MedPaxContactIDs[i] p := contactMap[string(id)] p.ContactID = append([]byte(nil), id...) b.MedPax = append(b.MedPax, p) } for i := range b.ResponsiblePilotContactIDs { id := b.ResponsiblePilotContactIDs[i] p := contactMap[string(id)] p.ContactID = append([]byte(nil), id...) b.ResponsiblePilots = append(b.ResponsiblePilots, p) } } return nil } func (r *BaseRepository) contactNameMap(ctx context.Context, contactIDs [][]byte) (map[string]base.BaseContactPerson, error) { uniq := make([][]byte, 0, len(contactIDs)) seen := map[string]struct{}{} for _, id := range contactIDs { if len(id) != 16 { continue } key := string(id) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} uniq = append(uniq, id) } if len(uniq) == 0 { return map[string]base.BaseContactPerson{}, nil } var rows []struct { ID []byte `gorm:"column:id"` FirstName string `gorm:"column:first_name"` LastName string `gorm:"column:last_name"` } if err := r.db.WithContext(ctx). Table("users"). Select("id, first_name, last_name"). Where("id IN ?", uniq). Find(&rows).Error; err != nil { return nil, err } out := make(map[string]base.BaseContactPerson, len(rows)) for i := range rows { out[string(rows[i].ID)] = base.BaseContactPerson{ FirstName: rows[i].FirstName, LastName: rows[i].LastName, } } return out, nil } func copyIDs(src [][]byte) [][]byte { out := make([][]byte, 0, len(src)) for _, id := range src { out = append(out, id) } return out } func (r *BaseRepository) categoryIDByType(ctx context.Context, categoryType string) ([]byte, error) { normalized, ok := base.NormalizeCategoryType(categoryType) if !ok { return nil, errors.New("invalid base category type") } return r.categoryIDByKey(ctx, normalized) } func (r *BaseRepository) categoryIDByKey(ctx context.Context, categoryKey string) ([]byte, error) { var row struct { ID []byte `gorm:"column:id"` } if err := r.db.WithContext(ctx). Table("base_categories"). Select("id"). Where("`key` = ?", strings.ToLower(strings.TrimSpace(categoryKey))). Take(&row).Error; err != nil { return nil, err } return append([]byte(nil), row.ID...), nil }