package mysql import ( "context" "errors" "gorm.io/gorm" complaint "wucher/internal/domain/complaint" ) type ComplaintRepository struct { db *gorm.DB } func NewComplaintRepository(db *gorm.DB) *ComplaintRepository { return &ComplaintRepository{db: db} } func (r *ComplaintRepository) Create(ctx context.Context, row *complaint.Complaint) error { return r.db.WithContext(ctx).Create(row).Error } func (r *ComplaintRepository) Update(ctx context.Context, row *complaint.Complaint) error { return r.db.WithContext(ctx).Save(row).Error } func (r *ComplaintRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error { return r.db.WithContext(ctx).Model(&complaint.Complaint{}).Where("id = ? AND deleted_at IS NULL", id).Updates(map[string]any{"deleted_at": gorm.Expr("NOW(3)"), "deleted_by": deletedBy, "updated_by": deletedBy}).Error } func (r *ComplaintRepository) GetByID(ctx context.Context, id []byte) (*complaint.Complaint, error) { var row complaint.Complaint err := r.db.WithContext(ctx). Where("id = ? AND deleted_at IS NULL", id). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *ComplaintRepository) ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]complaint.Complaint, int64, error) { rows := make([]complaint.Complaint, 0) var total int64 base := r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID) if err := base.Count(&total).Error; err != nil { return nil, 0, err } q := base.Order("reported_at DESC, created_at DESC") if limit > 0 { q = q.Limit(limit).Offset(offset) } err := q.Find(&rows).Error return rows, total, err } func (r *ComplaintRepository) List(ctx context.Context, filter complaint.ListFilter, sort string, limit, offset int) ([]complaint.Complaint, int64, error) { rows := make([]complaint.Complaint, 0) var total int64 base := r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("complaints.deleted_at IS NULL") if len(filter.HelicopterID) == 16 { base = base.Where("complaints.helicopter_id = ?", filter.HelicopterID) } if len(filter.FlightID) == 16 { base = base.Where("complaints.flight_id = ?", filter.FlightID) } if filter.HoldItemsOnly { base = base.Where("complaints.fixed_at IS NULL AND complaints.mel_severity > ?", 0) } if filter.Search != "" { like := "%" + filter.Search + "%" base = base.Where("complaints.description LIKE ? OR CAST(complaints.mel_severity AS CHAR) LIKE ?", like, like) } if filter.DateFrom != "" { base = base.Where("complaints.reported_at >= ?", filter.DateFrom+" 00:00:00") } if filter.DateTo != "" { base = base.Where("complaints.reported_at <= ?", filter.DateTo+" 23:59:59.999") } if filter.Person != "" { like := "%" + filter.Person + "%" base = base.Where( "EXISTS (SELECT 1 FROM users u WHERE u.id IN (complaints.reported_by, complaints.mel_classified_by, complaints.nsr_decided_by) "+ "AND TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) LIKE ?)", like, ) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } q := base if sort != "" { q = q.Order(sort) } else { q = q.Order("reported_at DESC, created_at DESC") } if limit > 0 { q = q.Limit(limit).Offset(offset) } if err := q.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *ComplaintRepository) HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error) { var count int64 err := r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("helicopter_id = ? AND deleted_at IS NULL AND fixed_at IS NULL", helicopterID). Count(&count).Error if err != nil { return false, err } return count > 0, nil } // OpenByHelicopterIDs returns the complaints the fleet-status Complaints column shows for // the given helicopters, in one query (no N+1). That is every OPEN defect (fixed_at IS // NULL) — including NSR-deferred and MEL-within-grace items, which are still open hold // items — PLUS the FIXED defects from each helicopter's latest flight (the flight of its // most recently reported complaint), so a just-resolved defect stays visible as the last // state until a newer flight reports. Fixed defects from older flights drop off here but // remain retrievable via the complaint history. A nil/empty helicopterIDs means "all". func (r *ComplaintRepository) OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error) { rows := make([]complaint.Complaint, 0) q := r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("deleted_at IS NULL"). Where("fixed_at IS NULL OR flight_id = (SELECT c2.flight_id FROM complaints c2 WHERE c2.helicopter_id = complaints.helicopter_id AND c2.deleted_at IS NULL AND c2.flight_id IS NOT NULL ORDER BY c2.reported_at DESC, c2.id DESC LIMIT 1)") if len(helicopterIDs) > 0 { q = q.Where("helicopter_id IN ?", helicopterIDs) } err := q.Order("reported_at DESC, created_at DESC").Find(&rows).Error return rows, err } func (r *ComplaintRepository) ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error) { rows := make([]complaint.Complaint, 0) // Grounding candidates: open (not fixed), not released (not NSR), and already // classified (mel_classified_at set). A pending complaint is not yet a grounding // defect — it neither grounds the aircraft nor blocks the EASA release gate until it // is classified. action_taken is intentionally NOT excluded — recording corrective // action no longer lifts the grounding (only an NSR or a signed EASA release does), // so IsGrounding decides. A nil/empty helicopterIDs means "all helicopters". q := r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("deleted_at IS NULL AND is_nsr = 0 AND fixed_at IS NULL AND mel_classified_at IS NOT NULL") if len(helicopterIDs) > 0 { q = q.Where("helicopter_id IN ?", helicopterIDs) } err := q.Find(&rows).Error return rows, err } func (r *ComplaintRepository) HelicopterHasUnactionedComplaint(ctx context.Context, helicopterID []byte) (bool, error) { var count int64 err := r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("helicopter_id = ? AND deleted_at IS NULL AND fixed_at IS NULL AND is_nsr = 0 AND (action_taken IS NULL OR action_taken = '')", helicopterID). Count(&count).Error if err != nil { return false, err } return count > 0, nil } func (r *ComplaintRepository) MarkFixedByHelicopter(ctx context.Context, helicopterID []byte, easaID []byte, actor []byte) error { return r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("helicopter_id = ? AND deleted_at IS NULL AND fixed_at IS NULL", helicopterID). Updates(map[string]any{"fixed_at": gorm.Expr("NOW(3)"), "fixed_by_easa_id": easaID, "updated_by": actor}).Error } // MarkFixedByComplaint closes a single complaint (by id) — used when a release is // scoped to one specific defect via complaint_id. No-op if already fixed/deleted. func (r *ComplaintRepository) MarkFixedByComplaint(ctx context.Context, complaintID []byte, easaID []byte, actor []byte) error { return r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("id = ? AND deleted_at IS NULL AND fixed_at IS NULL", complaintID). Updates(map[string]any{"fixed_at": gorm.Expr("NOW(3)"), "fixed_by_easa_id": easaID, "updated_by": actor}).Error } // MarkFixedByFlight closes every open complaint reported on a flight — used when a // release is scoped to a flight via flight_id. No-op for already fixed/deleted rows. func (r *ComplaintRepository) MarkFixedByFlight(ctx context.Context, flightID []byte, easaID []byte, actor []byte) error { return r.db.WithContext(ctx). Model(&complaint.Complaint{}). Where("flight_id = ? AND deleted_at IS NULL AND fixed_at IS NULL", flightID). Updates(map[string]any{"fixed_at": gorm.Expr("NOW(3)"), "fixed_by_easa_id": easaID, "updated_by": actor}).Error }