diff --git a/XrmBedrock.slnx b/XrmBedrock.slnx
index be82227..307215d 100644
--- a/XrmBedrock.slnx
+++ b/XrmBedrock.slnx
@@ -10,7 +10,7 @@
-
+
diff --git a/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs b/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs
deleted file mode 100644
index 9d6844b..0000000
--- a/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-#:package DataverseConnection@1.1.1
-#:package Azure.Identity@1.13.2
-#:project ../AdhocJobs.csproj
-#:property PublishAot=false
-
-using AdhocJobs;
-
-var ctx = JobSetup.Initialize(args);
-
-var accounts = ctx.Dao.RetrieveList(xrm => xrm.AccountSet)
- .Select(a => new { a.AccountId, a.Name })
- .ToList();
-
-Console.WriteLine($"Found {accounts.Count} accounts");
diff --git a/src/Dataverse/AdhocJobs/AdhocJobs.csproj b/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj
similarity index 70%
rename from src/Dataverse/AdhocJobs/AdhocJobs.csproj
rename to src/Dataverse/ConsoleJobs/ConsoleJobs.csproj
index 22bd39f..a4b7f01 100644
--- a/src/Dataverse/AdhocJobs/AdhocJobs.csproj
+++ b/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj
@@ -1,7 +1,7 @@
Exe
- FileBasedProgram
+ $(NoWarn);CA1303
@@ -10,5 +10,7 @@
-
+
+
+
diff --git a/src/Dataverse/ConsoleJobs/IJob.cs b/src/Dataverse/ConsoleJobs/IJob.cs
new file mode 100644
index 0000000..4911a85
--- /dev/null
+++ b/src/Dataverse/ConsoleJobs/IJob.cs
@@ -0,0 +1,6 @@
+namespace ConsoleJobs;
+
+public interface IJob
+{
+ void Run(JobContext ctx);
+}
diff --git a/src/Dataverse/AdhocJobs/JobContext.cs b/src/Dataverse/ConsoleJobs/JobContext.cs
similarity index 60%
rename from src/Dataverse/AdhocJobs/JobContext.cs
rename to src/Dataverse/ConsoleJobs/JobContext.cs
index 6c26228..931f129 100644
--- a/src/Dataverse/AdhocJobs/JobContext.cs
+++ b/src/Dataverse/ConsoleJobs/JobContext.cs
@@ -1,10 +1,12 @@
+using Microsoft.Extensions.Logging;
using Microsoft.Xrm.Sdk;
using SharedContext.Dao;
-namespace AdhocJobs;
+namespace ConsoleJobs;
public record JobContext(
IOrganizationService OrgService,
DataverseAccessObject Dao,
Uri DataverseUri,
- IServiceProvider Services);
+ IServiceProvider Services,
+ ILogger Logger);
diff --git a/src/Dataverse/AdhocJobs/JobSetup.cs b/src/Dataverse/ConsoleJobs/JobSetup.cs
similarity index 83%
rename from src/Dataverse/AdhocJobs/JobSetup.cs
rename to src/Dataverse/ConsoleJobs/JobSetup.cs
index edac973..6e37602 100644
--- a/src/Dataverse/AdhocJobs/JobSetup.cs
+++ b/src/Dataverse/ConsoleJobs/JobSetup.cs
@@ -5,14 +5,10 @@
using Microsoft.Xrm.Sdk;
using SharedContext.Dao;
-namespace AdhocJobs;
+namespace ConsoleJobs;
public static class JobSetup
{
-#pragma warning disable S1075 // URIs should not be hardcoded
- private const string DefaultUri = "https://YOUR-ENV.crm4.dynamics.com";
-#pragma warning restore S1075 // URIs should not be hardcoded
-
public static JobContext Initialize(string[] args)
{
return Initialize(args, null);
@@ -21,7 +17,7 @@ public static JobContext Initialize(string[] args)
public static JobContext Initialize(string[] args, Action? configureServices)
{
ArgumentNullException.ThrowIfNull(args);
- var dataverseUri = args.Length > 0 ? new Uri(args[0]) : new Uri(DefaultUri);
+ var dataverseUri = args.Length > 0 ? new Uri(args[0]) : throw new InvalidOperationException("Dataverse Url is needed as the first argument");
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddConsole());
@@ -36,10 +32,10 @@ public static JobContext Initialize(string[] args, Action? c
var sp = services.BuildServiceProvider();
var orgService = sp.GetRequiredService();
- var logger = sp.GetRequiredService().CreateLogger("BatchJob");
+ var logger = sp.GetRequiredService().CreateLogger("ConsoleJob");
var dao = new DataverseAccessObject(orgService, logger);
- var ctx = new JobContext(orgService, dao, dataverseUri, sp);
+ var ctx = new JobContext(orgService, dao, dataverseUri, sp, logger);
logger.LogInformation($"Connected to {ctx.DataverseUri}");
diff --git a/src/Dataverse/ConsoleJobs/Jobs/SolutionComponentJob.cs b/src/Dataverse/ConsoleJobs/Jobs/SolutionComponentJob.cs
new file mode 100644
index 0000000..830174b
--- /dev/null
+++ b/src/Dataverse/ConsoleJobs/Jobs/SolutionComponentJob.cs
@@ -0,0 +1,66 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+
+namespace ConsoleJobs.Jobs;
+
+public class SolutionComponentJob : IJob
+{
+ public void Run(JobContext ctx)
+ {
+ ArgumentNullException.ThrowIfNull(ctx);
+
+ ctx.Logger.LogInformation("Fetching solution components modified in last 7 days...");
+
+ var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
+
+ var query = new QueryExpression("solutioncomponent")
+ {
+ ColumnSet = new ColumnSet("objectid", "componenttype", "modifiedonbehalfby", "createdonbehalfby", "modifiedon"),
+ Criteria = new FilterExpression
+ {
+ Conditions =
+ {
+ new ConditionExpression("modifiedon", ConditionOperator.GreaterEqual, sevenDaysAgo),
+ },
+ },
+ };
+
+ var results = ctx.OrgService.RetrieveMultiple(query);
+
+ ctx.Logger.LogInformation("Found {Count} solution components", results.Entities.Count);
+
+ var grouped = results.Entities
+ .GroupBy(e => e.GetAttributeValue("modifiedonbehalfby")?.Id ?? Guid.Empty)
+ .ToList();
+
+ foreach (var group in grouped)
+ {
+ var firstItem = group.First();
+ var modifiedBy = firstItem.GetAttributeValue("modifiedonbehalfby");
+ var createdBy = firstItem.GetAttributeValue("createdonbehalfby");
+
+ var modifiedByName = modifiedBy?.Name ?? "(none)";
+ var createdByName = createdBy?.Name ?? "(none)";
+
+ ctx.Logger.LogInformation(
+ "Modified by: {ModifiedBy} | Created by: {CreatedBy} | Count: {Count}",
+ modifiedByName,
+ createdByName,
+ group.Count());
+
+ foreach (var component in group)
+ {
+ var objectId = component.GetAttributeValue("objectid");
+ var componentType = component.GetAttributeValue("componenttype")?.Value;
+ var modifiedOn = component.GetAttributeValue("modifiedon");
+
+ ctx.Logger.LogInformation(
+ " ObjectId: {ObjectId} | ComponentType: {ComponentType} | ModifiedOn: {ModifiedOn}",
+ objectId,
+ componentType,
+ modifiedOn);
+ }
+ }
+ }
+}
diff --git a/src/Dataverse/ConsoleJobs/Jobs/WhoAmIJob.cs b/src/Dataverse/ConsoleJobs/Jobs/WhoAmIJob.cs
new file mode 100644
index 0000000..9521cb5
--- /dev/null
+++ b/src/Dataverse/ConsoleJobs/Jobs/WhoAmIJob.cs
@@ -0,0 +1,20 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Extensions.Logging;
+
+namespace ConsoleJobs.Jobs;
+
+public class WhoAmIJob : IJob
+{
+ public void Run(JobContext ctx)
+ {
+ ArgumentNullException.ThrowIfNull(ctx);
+
+ ctx.Logger.LogInformation("Executing WhoAmI request...");
+
+ var response = (WhoAmIResponse)ctx.OrgService.Execute(new WhoAmIRequest());
+
+ ctx.Logger.LogInformation("User ID: {UserId}", response.UserId);
+ ctx.Logger.LogInformation("Business Unit ID: {BusinessUnitId}", response.BusinessUnitId);
+ ctx.Logger.LogInformation("Organization ID: {OrganizationId}", response.OrganizationId);
+ }
+}
diff --git a/src/Dataverse/ConsoleJobs/Program.cs b/src/Dataverse/ConsoleJobs/Program.cs
new file mode 100644
index 0000000..734ec35
--- /dev/null
+++ b/src/Dataverse/ConsoleJobs/Program.cs
@@ -0,0 +1,35 @@
+using System.Reflection;
+using ConsoleJobs;
+
+static string GetJobName(Type t)
+{
+ var name = t.Name;
+ return name.EndsWith("Job", StringComparison.Ordinal) ? name[..^3] : name;
+}
+
+var jobs = Assembly.GetExecutingAssembly()
+ .GetTypes()
+ .Where(t => typeof(IJob).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)
+ .ToDictionary(GetJobName, t => t, StringComparer.OrdinalIgnoreCase);
+
+if (args.Length == 0 || !jobs.TryGetValue(args[0], out var jobType))
+{
+ Console.WriteLine("Usage: ConsoleJobs [dataverse-url]");
+ Console.WriteLine();
+ Console.WriteLine("Available jobs:");
+ foreach (var name in jobs.Keys.OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
+ {
+ Console.WriteLine($" {name}");
+ }
+
+ return 1;
+}
+
+var job = (IJob)Activator.CreateInstance(jobType)!;
+
+var jobArgs = args.Skip(1).ToArray();
+var ctx = JobSetup.Initialize(jobArgs);
+
+job.Run(ctx);
+
+return 0;
diff --git a/src/Dataverse/ConsoleJobs/README.md b/src/Dataverse/ConsoleJobs/README.md
new file mode 100644
index 0000000..88b5732
--- /dev/null
+++ b/src/Dataverse/ConsoleJobs/README.md
@@ -0,0 +1,46 @@
+# ConsoleJobs
+
+Ad-hoc console jobs for Dataverse operations.
+
+## Usage
+
+```bash
+# List available jobs
+dotnet run --project src/Dataverse/ConsoleJobs/ConsoleJobs.csproj
+
+# Run a job with specific Dataverse URL
+dotnet run --project src/Dataverse/ConsoleJobs/ConsoleJobs.csproj -- SolutionComponent https://your-env.crm4.dynamics.com
+```
+
+## Authentication
+
+Uses `AzureCliCredential` - authenticate via Azure CLI before running:
+
+```bash
+az login
+```
+
+## Adding a new job
+
+1. Create a new class in the `Jobs` folder
+2. Implement `IJob` interface
+3. Name the class with a `Job` suffix (e.g., `MyCustomJob`)
+
+The job will be auto-discovered and available as `MyCustom`.
+
+```csharp
+using Microsoft.Extensions.Logging;
+
+namespace ConsoleJobs.Jobs;
+
+public class MyCustomJob : IJob
+{
+ public void Run(JobContext ctx)
+ {
+ ArgumentNullException.ThrowIfNull(ctx);
+
+ ctx.Logger.LogInformation("Running my custom job...");
+ // Use ctx.OrgService or ctx.Dao for Dataverse operations
+ }
+}
+```