package migration import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/ultisuite/ulti-backend/internal/api/query" ) const ( ItemStatusImported = "imported" ItemStatusFailed = "failed" ItemStatusSkipped = "skipped" ) // JobAuditItem is one row in a migration job item audit report. type JobAuditItem struct { SourceID string `json:"source_id"` RelPath string `json:"rel_path,omitempty"` Status string `json:"status"` Reason string `json:"reason,omitempty"` ImportedAt string `json:"imported_at"` } // JobAuditSummary counts items by status for a migration job. type JobAuditSummary struct { Service string `json:"service"` Imported int64 `json:"imported"` Failed int64 `json:"failed"` Skipped int64 `json:"skipped"` Total int64 `json:"total"` ByStatus map[string]int64 `json:"by_status,omitempty"` } func (s *Service) verifyJobInProject(ctx context.Context, projectID, jobID string) (service string, err error) { err = s.db.QueryRow(ctx, ` SELECT service FROM migration_jobs WHERE id = $1::uuid AND project_id = $2::uuid `, jobID, projectID).Scan(&service) if errors.Is(err, pgx.ErrNoRows) { return "", fmt.Errorf("job not found") } return service, err } func (s *Service) JobAuditSummary(ctx context.Context, projectID, jobID string) (JobAuditSummary, error) { service, err := s.verifyJobInProject(ctx, projectID, jobID) if err != nil { return JobAuditSummary{}, err } rows, err := s.db.Query(ctx, ` SELECT status, COUNT(*) FROM migration_imported_items WHERE job_id = $1::uuid GROUP BY status `, jobID) if err != nil { return JobAuditSummary{}, err } defer rows.Close() summary := JobAuditSummary{Service: service, ByStatus: map[string]int64{}} for rows.Next() { var status string var count int64 if err := rows.Scan(&status, &count); err != nil { return JobAuditSummary{}, err } summary.ByStatus[status] = count summary.Total += count switch status { case ItemStatusImported: summary.Imported = count case ItemStatusFailed: summary.Failed = count case ItemStatusSkipped: summary.Skipped = count } } return summary, rows.Err() } func (s *Service) ListJobAudit( ctx context.Context, projectID, jobID, statusFilter string, params query.ListParams, ) ([]JobAuditItem, query.PaginationMeta, error) { if _, err := s.verifyJobInProject(ctx, projectID, jobID); err != nil { return nil, query.PaginationMeta{}, err } statusFilter = normalizeAuditStatusFilter(statusFilter) var total int64 countSQL := `SELECT COUNT(*) FROM migration_imported_items WHERE job_id = $1::uuid` countArgs := []any{jobID} if statusFilter != "" { countSQL += ` AND status = $2` countArgs = append(countArgs, statusFilter) } if err := s.db.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { return nil, query.PaginationMeta{}, err } listSQL := ` SELECT source_id, rel_path, status, reason, imported_at::text FROM migration_imported_items WHERE job_id = $1::uuid ` listArgs := []any{jobID} if statusFilter != "" { listSQL += ` AND status = $2` listArgs = append(listArgs, statusFilter) } listSQL += ` ORDER BY imported_at DESC, source_id ASC LIMIT $` + fmt.Sprint(len(listArgs)+1) + ` OFFSET $` + fmt.Sprint(len(listArgs)+2) listArgs = append(listArgs, params.Limit(), params.Offset()) rows, err := s.db.Query(ctx, listSQL, listArgs...) if err != nil { return nil, query.PaginationMeta{}, err } defer rows.Close() out := make([]JobAuditItem, 0, params.Limit()) for rows.Next() { var item JobAuditItem if err := rows.Scan(&item.SourceID, &item.RelPath, &item.Status, &item.Reason, &item.ImportedAt); err != nil { return nil, query.PaginationMeta{}, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, query.PaginationMeta{}, err } return out, params.Meta(&total), nil } func normalizeAuditStatusFilter(raw string) string { switch raw { case ItemStatusImported, ItemStatusFailed, ItemStatusSkipped: return raw default: return "" } } func incJobStat(stats map[string]any, key string) { if stats == nil { return } v, _ := stats[key].(float64) stats[key] = v + 1 }