feat(04-05): implement BulkSiteService with PnP Framework site creation
- BulkSiteService creates Team sites via TeamSiteCollectionCreationInformation with owners/members - BulkSiteService creates Communication sites via CommunicationSiteCollectionCreationInformation with generated URL - Per-site error handling via BulkOperationRunner with continue-on-error semantics - SanitizeAlias generates URL-safe aliases from site names for Communication sites - BulkSiteServiceTests: 3 pass (interface check + model defaults + CSV parsing), 3 skip (live SP) - Fixed pre-existing BulkMemberService.cs Group type ambiguity (MSCSC.Group vs Graph.Models.Group)
This commit is contained in:
187
SharepointToolbox/Services/BulkMemberService.cs
Normal file
187
SharepointToolbox/Services/BulkMemberService.cs
Normal file
@@ -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<BulkOperationSummary<BulkMemberRow>> AddMembersAsync(
|
||||
ClientContext ctx,
|
||||
string clientId,
|
||||
IReadOnlyList<BulkMemberRow> rows,
|
||||
IProgress<OperationProgress> 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<OperationProgress> 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<string?> 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<OperationProgress> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user