--- phase: 04 plan: 05 title: BulkSiteService Implementation status: pending wave: 1 depends_on: - 04-01 files_modified: - SharepointToolbox/Services/BulkSiteService.cs - SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs autonomous: true requirements: - BULK-03 - BULK-04 - BULK-05 must_haves: truths: - "BulkSiteService creates Team sites using PnP Framework TeamSiteCollectionCreationInformation" - "BulkSiteService creates Communication sites using CommunicationSiteCollectionCreationInformation" - "Team sites require alias and at least one owner (validated by CsvValidationService upstream)" - "BulkOperationRunner handles per-site error reporting and cancellation" - "Each created site URL is logged for user reference" artifacts: - path: "SharepointToolbox/Services/BulkSiteService.cs" provides: "Bulk site creation via PnP Framework" exports: ["BulkSiteService"] key_links: - from: "BulkSiteService.cs" to: "BulkOperationRunner.cs" via: "per-site delegation" pattern: "BulkOperationRunner.RunAsync" - from: "BulkSiteService.cs" to: "PnP.Framework.Sites.SiteCollection" via: "CreateAsync extension method" pattern: "CreateSiteAsync|CreateAsync" --- # Plan 04-05: BulkSiteService Implementation ## Goal Implement `BulkSiteService` for creating multiple SharePoint sites in bulk from CSV rows. Uses PnP Framework `SiteCollection.CreateAsync` with `TeamSiteCollectionCreationInformation` for Team sites and `CommunicationSiteCollectionCreationInformation` for Communication sites. Per-site error reporting via `BulkOperationRunner`. ## Context `IBulkSiteService`, `BulkSiteRow`, and `BulkOperationRunner` are from Plan 04-01. PnP.Framework 1.18.0 is already installed. Site creation is async on the SharePoint side (Pitfall 3 from research) — the `CreateAsync` method returns when the site is provisioned, but a Team site may take 2-3 minutes. Key research findings: - `ctx.CreateSiteAsync(TeamSiteCollectionCreationInformation)` creates Team site (M365 Group-connected) - `ctx.CreateSiteAsync(CommunicationSiteCollectionCreationInformation)` creates Communication site - Team sites MUST have alias and at least one owner - Communication sites need a URL in format `https://tenant.sharepoint.com/sites/alias` ## Tasks ### Task 1: Implement BulkSiteService **Files:** - `SharepointToolbox/Services/BulkSiteService.cs` **Action:** ```csharp using Microsoft.SharePoint.Client; using PnP.Framework.Sites; using Serilog; using SharepointToolbox.Core.Models; using SharepointToolbox.Infrastructure.Auth; 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(); } } ``` **Verify:** ```bash dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q ``` **Done:** BulkSiteService compiles. Creates Team sites (with alias + owners) and Communication sites (with generated URL) via PnP Framework. Per-site error handling via BulkOperationRunner. ### Task 2: Create BulkSiteService unit tests **Files:** - `SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs` **Action:** ```csharp 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() { } } ``` **Verify:** ```bash dotnet build SharepointToolbox.slnx --no-restore -q && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~BulkSiteService" -q ``` **Done:** BulkSiteService tests pass (3 pass, 3 skip). Service compiles with Team + Communication site creation. **Commit:** `feat(04-05): implement BulkSiteService with PnP Framework site creation`