diff --git a/SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs b/SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs new file mode 100644 index 0000000..48a00d5 --- /dev/null +++ b/SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs @@ -0,0 +1,56 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +public class BulkSiteServiceTests +{ + [Fact] + public void BulkSiteService_Implements_IBulkSiteService() + { + Assert.True(typeof(IBulkSiteService).IsAssignableFrom(typeof(BulkSiteService))); + } + + [Fact] + public void BulkSiteRow_DefaultValues() + { + var row = new BulkSiteRow(); + Assert.Equal(string.Empty, row.Name); + Assert.Equal(string.Empty, row.Alias); + Assert.Equal(string.Empty, row.Type); + Assert.Equal(string.Empty, row.Template); + Assert.Equal(string.Empty, row.Owners); + Assert.Equal(string.Empty, row.Members); + } + + [Fact] + public void BulkSiteRow_ParsesCommaSeparatedEmails() + { + var row = new BulkSiteRow + { + Name = "Test Site", + Alias = "test-site", + Type = "Team", + Owners = "admin@test.com, user@test.com", + Members = "member1@test.com,member2@test.com" + }; + + Assert.Equal("Test Site", row.Name); + Assert.Contains("admin@test.com", row.Owners); + } + + [Fact(Skip = "Requires live SharePoint admin context")] + public async Task CreateSitesAsync_TeamSite_CreatesWithOwners() + { + } + + [Fact(Skip = "Requires live SharePoint admin context")] + public async Task CreateSitesAsync_CommunicationSite_CreatesWithUrl() + { + } + + [Fact(Skip = "Requires live SharePoint admin context")] + public async Task CreateSitesAsync_MixedTypes_HandlesEachCorrectly() + { + } +} diff --git a/SharepointToolbox/Services/BulkMemberService.cs b/SharepointToolbox/Services/BulkMemberService.cs new file mode 100644 index 0000000..c7aacfd --- /dev/null +++ b/SharepointToolbox/Services/BulkMemberService.cs @@ -0,0 +1,187 @@ +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.SharePoint.Client; +using Serilog; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; +using AuthGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; +using SpGroup = Microsoft.SharePoint.Client.Group; + +namespace SharepointToolbox.Services; + +public class BulkMemberService : IBulkMemberService +{ + private readonly AuthGraphClientFactory _graphClientFactory; + + public BulkMemberService(AuthGraphClientFactory graphClientFactory) + { + _graphClientFactory = graphClientFactory; + } + + public async Task> AddMembersAsync( + ClientContext ctx, + string clientId, + IReadOnlyList rows, + IProgress progress, + CancellationToken ct) + { + return await BulkOperationRunner.RunAsync( + rows, + async (row, idx, token) => + { + await AddSingleMemberAsync(ctx, clientId, row, progress, token); + }, + progress, + ct); + } + + private async Task AddSingleMemberAsync( + ClientContext ctx, + string clientId, + BulkMemberRow row, + IProgress progress, + CancellationToken ct) + { + var siteUrl = row.GroupUrl; + if (string.IsNullOrWhiteSpace(siteUrl)) + { + // Fallback: use the context URL + group name for classic SP group + await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct); + return; + } + + // Try Graph API first for M365 Groups + try + { + var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct); + + // Resolve the group ID from the site URL + var groupId = await ResolveGroupIdAsync(graphClient, siteUrl, ct); + if (groupId != null) + { + await AddViaGraphAsync(graphClient, groupId, row.Email, row.Role, ct); + Log.Information("Added {Email} to M365 group {Group} via Graph", row.Email, row.GroupName); + return; + } + } + catch (Exception ex) + { + Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}", + siteUrl, ex.Message); + } + + // CSOM fallback for classic SharePoint groups + await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct); + } + + private static async Task AddViaGraphAsync( + GraphServiceClient graphClient, + string groupId, + string email, + string role, + CancellationToken ct) + { + // Resolve user by email + var user = await graphClient.Users[email].GetAsync(cancellationToken: ct); + if (user == null) + throw new InvalidOperationException($"User not found: {email}"); + + var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}"; + + if (role.Equals("Owner", StringComparison.OrdinalIgnoreCase)) + { + var body = new ReferenceCreate { OdataId = userRef }; + await graphClient.Groups[groupId].Owners.Ref.PostAsync(body, cancellationToken: ct); + } + else + { + var body = new ReferenceCreate { OdataId = userRef }; + await graphClient.Groups[groupId].Members.Ref.PostAsync(body, cancellationToken: ct); + } + } + + private static async Task ResolveGroupIdAsync( + GraphServiceClient graphClient, + string siteUrl, + CancellationToken ct) + { + try + { + // Parse site URL to get hostname and site path + var uri = new Uri(siteUrl); + var hostname = uri.Host; + var sitePath = uri.AbsolutePath.TrimEnd('/'); + + var site = await graphClient.Sites[$"{hostname}:{sitePath}"].GetAsync(cancellationToken: ct); + if (site?.Id == null) return null; + + // Try to get the associated group + // Site.Id format: "hostname,siteCollectionId,siteId" + var parts = site.Id.Split(','); + if (parts.Length >= 2) + { + try + { + var groups = await graphClient.Groups + .GetAsync(r => + { + r.QueryParameters.Filter = $"resourceProvisioningOptions/any(x:x eq 'Team')"; + r.QueryParameters.Select = new[] { "id", "displayName", "resourceProvisioningOptions" }; + }, cancellationToken: ct); + + // Find group associated with this site + if (groups?.Value != null) + { + foreach (var group in groups.Value) + { + if (group.Id != null) return group.Id; + } + } + } + catch { /* not a group-connected site */ } + } + + return null; + } + catch + { + return null; + } + } + + private static async Task AddToClassicGroupAsync( + ClientContext ctx, + string groupName, + string email, + string role, + IProgress progress, + CancellationToken ct) + { + var web = ctx.Web; + var groups = web.SiteGroups; + ctx.Load(groups); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + Microsoft.SharePoint.Client.Group? targetGroup = null; + foreach (var group in groups) + { + if (group.Title.Equals(groupName, StringComparison.OrdinalIgnoreCase)) + { + targetGroup = group; + break; + } + } + + if (targetGroup == null) + throw new InvalidOperationException($"SharePoint group not found: {groupName}"); + + var user = web.EnsureUser(email); + ctx.Load(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + targetGroup.Users.AddUser(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + Log.Information("Added {Email} to classic SP group {Group}", email, groupName); + } +} diff --git a/SharepointToolbox/Services/BulkSiteService.cs b/SharepointToolbox/Services/BulkSiteService.cs new file mode 100644 index 0000000..40a9936 --- /dev/null +++ b/SharepointToolbox/Services/BulkSiteService.cs @@ -0,0 +1,194 @@ +using Microsoft.SharePoint.Client; +using PnP.Framework.Sites; +using Serilog; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +public class BulkSiteService : IBulkSiteService +{ + public async Task> CreateSitesAsync( + ClientContext adminCtx, + IReadOnlyList rows, + IProgress progress, + CancellationToken ct) + { + return await BulkOperationRunner.RunAsync( + rows, + async (row, idx, token) => + { + var siteUrl = await CreateSingleSiteAsync(adminCtx, row, progress, token); + Log.Information("Created site: {Name} ({Type}) at {Url}", row.Name, row.Type, siteUrl); + }, + progress, + ct); + } + + private static async Task CreateSingleSiteAsync( + ClientContext adminCtx, + BulkSiteRow row, + IProgress progress, + CancellationToken ct) + { + if (row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase)) + { + return await CreateTeamSiteAsync(adminCtx, row, progress, ct); + } + else if (row.Type.Equals("Communication", StringComparison.OrdinalIgnoreCase)) + { + return await CreateCommunicationSiteAsync(adminCtx, row, progress, ct); + } + else + { + throw new InvalidOperationException($"Unknown site type: {row.Type}. Expected 'Team' or 'Communication'."); + } + } + + private static async Task CreateTeamSiteAsync( + ClientContext adminCtx, + BulkSiteRow row, + IProgress progress, + CancellationToken ct) + { + var owners = ParseEmails(row.Owners); + var members = ParseEmails(row.Members); + + var creationInfo = new TeamSiteCollectionCreationInformation + { + DisplayName = row.Name, + Alias = row.Alias, + Description = string.Empty, + IsPublic = false, + Owners = owners.ToArray(), + }; + + progress.Report(new OperationProgress(0, 0, $"Creating Team site: {row.Name}...")); + + using var siteCtx = await adminCtx.CreateSiteAsync(creationInfo); + siteCtx.Load(siteCtx.Web, w => w.Url); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + + var siteUrl = siteCtx.Web.Url; + + // Add additional members if specified + if (members.Count > 0) + { + foreach (var memberEmail in members) + { + ct.ThrowIfCancellationRequested(); + try + { + var user = siteCtx.Web.EnsureUser(memberEmail); + siteCtx.Load(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + + // Add to Members group + var membersGroup = siteCtx.Web.AssociatedMemberGroup; + membersGroup.Users.AddUser(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + } + catch (Exception ex) + { + Log.Warning("Failed to add member {Email} to {Site}: {Error}", + memberEmail, row.Name, ex.Message); + } + } + } + + return siteUrl; + } + + private static async Task CreateCommunicationSiteAsync( + ClientContext adminCtx, + BulkSiteRow row, + IProgress progress, + CancellationToken ct) + { + // Build the site URL from alias or sanitized name + var alias = !string.IsNullOrWhiteSpace(row.Alias) ? row.Alias : SanitizeAlias(row.Name); + var tenantUrl = new Uri(adminCtx.Url); + var siteUrl = $"https://{tenantUrl.Host}/sites/{alias}"; + + var creationInfo = new CommunicationSiteCollectionCreationInformation + { + Title = row.Name, + Url = siteUrl, + Description = string.Empty, + }; + + progress.Report(new OperationProgress(0, 0, $"Creating Communication site: {row.Name}...")); + + using var siteCtx = await adminCtx.CreateSiteAsync(creationInfo); + siteCtx.Load(siteCtx.Web, w => w.Url); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + + var createdUrl = siteCtx.Web.Url; + + // Add owners and members if specified + var owners = ParseEmails(row.Owners); + var members = ParseEmails(row.Members); + + foreach (var ownerEmail in owners) + { + ct.ThrowIfCancellationRequested(); + try + { + var user = siteCtx.Web.EnsureUser(ownerEmail); + siteCtx.Load(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + + var ownersGroup = siteCtx.Web.AssociatedOwnerGroup; + ownersGroup.Users.AddUser(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + } + catch (Exception ex) + { + Log.Warning("Failed to add owner {Email} to {Site}: {Error}", + ownerEmail, row.Name, ex.Message); + } + } + + foreach (var memberEmail in members) + { + ct.ThrowIfCancellationRequested(); + try + { + var user = siteCtx.Web.EnsureUser(memberEmail); + siteCtx.Load(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + + var membersGroup = siteCtx.Web.AssociatedMemberGroup; + membersGroup.Users.AddUser(user); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); + } + catch (Exception ex) + { + Log.Warning("Failed to add member {Email} to {Site}: {Error}", + memberEmail, row.Name, ex.Message); + } + } + + return createdUrl; + } + + private static List ParseEmails(string commaSeparated) + { + if (string.IsNullOrWhiteSpace(commaSeparated)) + return new List(); + + return commaSeparated + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(e => !string.IsNullOrWhiteSpace(e)) + .ToList(); + } + + private static string SanitizeAlias(string name) + { + // Remove special characters, spaces -> dashes, lowercase + var sanitized = new string(name + .Where(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-') + .ToArray()); + return sanitized.Replace(' ', '-').ToLowerInvariant(); + } +}