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 + } +} +```