using Microsoft.Graph; using Microsoft.Graph.Models; using Microsoft.SharePoint.Client; using Serilog; using SharepointToolbox.Web.Core.Helpers; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Services.Audit; using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory; namespace SharepointToolbox.Web.Services; public class BulkMemberService : IBulkMemberService { private readonly AppGraphClientFactory _graphClientFactory; private readonly IAuditService _audit; public BulkMemberService(AppGraphClientFactory graphClientFactory, IAuditService audit) { _graphClientFactory = graphClientFactory; _audit = audit; } public async Task> AddMembersAsync( ClientContext ctx, TenantProfile profile, IReadOnlyList rows, IProgress progress, CancellationToken ct) { var result = await BulkOperationRunner.RunAsync(rows, async (row, idx, token) => await AddSingleMemberAsync(ctx, profile, row, progress, token), progress, ct); var sites = rows.Select(r => r.GroupUrl ?? ctx.Url).Distinct().ToList(); await _audit.LogAsync("BulkAddMembers", profile.Name, sites, $"{result.SuccessCount} succeeded, {(result.TotalCount - result.SuccessCount)} failed"); return result; } private async Task AddSingleMemberAsync( ClientContext ctx, TenantProfile profile, BulkMemberRow row, IProgress progress, CancellationToken ct) { if (string.IsNullOrWhiteSpace(row.GroupUrl)) { await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct); return; } try { var graphClient = await _graphClientFactory.CreateClientAsync(profile); var groupId = await ResolveGroupIdAsync(graphClient, row.GroupUrl, 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 (OperationCanceledException) { throw; } catch (Exception ex) { Log.Warning("Graph API failed for {Url}, falling back to CSOM: {Error}", row.GroupUrl, ex.Message); } 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) { var user = await graphClient.Users[email].GetAsync(cancellationToken: ct); if (user?.Id == null) throw new InvalidOperationException($"User not found: {email}"); var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}"; var body = new ReferenceCreate { OdataId = userRef }; if (role.Equals("Owner", StringComparison.OrdinalIgnoreCase)) await graphClient.Groups[groupId].Owners.Ref.PostAsync(body, cancellationToken: ct); else await graphClient.Groups[groupId].Members.Ref.PostAsync(body, cancellationToken: ct); } private static async Task ResolveGroupIdAsync(GraphServiceClient graphClient, string siteUrl, CancellationToken ct) { try { var uri = new Uri(siteUrl); var site = await graphClient.Sites[$"{uri.Host}:{uri.AbsolutePath.TrimEnd('/')}"].GetAsync(cancellationToken: ct); if (site?.Id == null) return null; var groups = await graphClient.Groups.GetAsync(r => { r.QueryParameters.Filter = "resourceProvisioningOptions/any(x:x eq 'Team')"; r.QueryParameters.Select = new[] { "id" }; }, cancellationToken: ct); return groups?.Value?.FirstOrDefault()?.Id; } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Debug("Group resolve failed for {Url}: {Error}", siteUrl, ex.Message); return null; } } private static async Task AddToClassicGroupAsync( ClientContext ctx, string groupName, string email, string role, IProgress progress, CancellationToken ct) { ctx.Load(ctx.Web.SiteGroups); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); Microsoft.SharePoint.Client.Group? targetGroup = null; foreach (var group in ctx.Web.SiteGroups) if (group.Title.Equals(groupName, StringComparison.OrdinalIgnoreCase)) { targetGroup = group; break; } if (targetGroup == null) throw new InvalidOperationException($"SharePoint group not found: {groupName}"); var user = ctx.Web.EnsureUser(email); ctx.Load(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); targetGroup.Users.AddUser(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); } }