package migration import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5" ) // AdminJob is a migration job enriched with the invited user email for admin dashboards. type AdminJob struct { Job UserEmail string `json:"user_email"` } func (s *Service) ListProjectJobs(ctx context.Context, projectID string) ([]AdminJob, error) { rows, err := s.db.Query(ctx, ` SELECT j.id::text, j.project_id::text, j.user_id::text, j.service, j.status, j.cursor_json, j.stats_json, j.error, j.started_at::text, j.completed_at::text, COALESCE(i.email, '') FROM migration_jobs j LEFT JOIN migration_invites i ON i.project_id = j.project_id AND i.user_id = j.user_id WHERE j.project_id = $1::uuid ORDER BY COALESCE(i.email, ''), j.service ASC `, projectID) if err != nil { return nil, err } defer rows.Close() var out []AdminJob for rows.Next() { var row AdminJob var cursorRaw, statsRaw []byte if err := rows.Scan( &row.ID, &row.ProjectID, &row.UserID, &row.Service, &row.Status, &cursorRaw, &statsRaw, &row.Error, &row.StartedAt, &row.CompletedAt, &row.UserEmail, ); err != nil { return nil, err } _ = json.Unmarshal(cursorRaw, &row.CursorJSON) _ = json.Unmarshal(statsRaw, &row.StatsJSON) if row.CursorJSON == nil { row.CursorJSON = map[string]any{} } if row.StatsJSON == nil { row.StatsJSON = map[string]any{} } out = append(out, row) } return out, rows.Err() } func (s *Service) RetryJob(ctx context.Context, projectID, jobID string) (Job, error) { var row Job var cursorRaw, statsRaw []byte err := s.db.QueryRow(ctx, ` UPDATE migration_jobs SET status = 'pending', error = '', updated_at = NOW() WHERE id = $1::uuid AND project_id = $2::uuid AND status = 'failed' RETURNING id::text, project_id::text, user_id::text, service, status, cursor_json, stats_json, error, started_at::text, completed_at::text `, jobID, projectID).Scan( &row.ID, &row.ProjectID, &row.UserID, &row.Service, &row.Status, &cursorRaw, &statsRaw, &row.Error, &row.StartedAt, &row.CompletedAt, ) if errors.Is(err, pgx.ErrNoRows) { return Job{}, fmt.Errorf("job not found or not retryable") } if err != nil { return Job{}, err } _ = json.Unmarshal(cursorRaw, &row.CursorJSON) _ = json.Unmarshal(statsRaw, &row.StatsJSON) if row.CursorJSON == nil { row.CursorJSON = map[string]any{} } if row.StatsJSON == nil { row.StatsJSON = map[string]any{} } return row, nil } func (s *Service) RetryFailedJobs(ctx context.Context, projectID string) (int64, error) { tag, err := s.db.Exec(ctx, ` UPDATE migration_jobs SET status = 'pending', error = '', updated_at = NOW() WHERE project_id = $1::uuid AND status = 'failed' `, projectID) if err != nil { return 0, err } return tag.RowsAffected(), nil } func (s *Service) ResetJobCursor(ctx context.Context, projectID, jobID string) (Job, error) { tx, err := s.db.Begin(ctx) if err != nil { return Job{}, err } defer tx.Rollback(ctx) var status string err = tx.QueryRow(ctx, ` SELECT status FROM migration_jobs WHERE id = $1::uuid AND project_id = $2::uuid `, jobID, projectID).Scan(&status) if errors.Is(err, pgx.ErrNoRows) { return Job{}, fmt.Errorf("job not found") } if err != nil { return Job{}, err } if status == "running" { return Job{}, fmt.Errorf("job running; wait for completion before reset") } if _, err := tx.Exec(ctx, ` DELETE FROM migration_imported_items WHERE job_id = $1::uuid `, jobID); err != nil { return Job{}, err } var row Job var cursorRaw, statsRaw []byte err = tx.QueryRow(ctx, ` UPDATE migration_jobs SET status = 'pending', cursor_json = '{}'::jsonb, stats_json = '{}'::jsonb, error = '', started_at = NULL, completed_at = NULL, updated_at = NOW() WHERE id = $1::uuid AND project_id = $2::uuid RETURNING id::text, project_id::text, user_id::text, service, status, cursor_json, stats_json, error, started_at::text, completed_at::text `, jobID, projectID).Scan( &row.ID, &row.ProjectID, &row.UserID, &row.Service, &row.Status, &cursorRaw, &statsRaw, &row.Error, &row.StartedAt, &row.CompletedAt, ) if err != nil { return Job{}, err } if err := tx.Commit(ctx); err != nil { return Job{}, err } _ = json.Unmarshal(cursorRaw, &row.CursorJSON) _ = json.Unmarshal(statsRaw, &row.StatsJSON) if row.CursorJSON == nil { row.CursorJSON = map[string]any{} } if row.StatsJSON == nil { row.StatsJSON = map[string]any{} } return row, nil }