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); } }