Files
Sharepoint-Toolbox/.planning/phases/04-bulk-operations-and-provisioning/04-05-PLAN.md
Dev d73e50948d docs(04): create Phase 4 plan — 10 plans for Bulk Operations and Provisioning
Wave 0: models, interfaces, BulkOperationRunner, test scaffolds
Wave 1: CsvValidationService, TemplateRepository, FileTransferService,
        BulkMemberService, BulkSiteService, TemplateService, FolderStructureService
Wave 2: Localization, shared dialogs, example CSV resources
Wave 3: TransferVM+View, BulkMembers/BulkSites/FolderStructure VMs+Views,
        TemplatesVM+View, DI registration, MainWindow wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:38:33 +02:00

12 KiB

phase, plan, title, status, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan title status wave depends_on files_modified autonomous requirements must_haves
04 05 BulkSiteService Implementation pending 1
04-01
SharepointToolbox/Services/BulkSiteService.cs
SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs
true
BULK-03
BULK-04
BULK-05
truths artifacts key_links
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
path provides exports
SharepointToolbox/Services/BulkSiteService.cs Bulk site creation via PnP Framework
BulkSiteService
from to via pattern
BulkSiteService.cs BulkOperationRunner.cs per-site delegation BulkOperationRunner.RunAsync
from to via pattern
BulkSiteService.cs PnP.Framework.Sites.SiteCollection CreateAsync extension method 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:

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<BulkOperationSummary<BulkSiteRow>> CreateSitesAsync(
        ClientContext adminCtx,
        IReadOnlyList<BulkSiteRow> rows,
        IProgress<OperationProgress> 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<string> CreateSingleSiteAsync(
        ClientContext adminCtx,
        BulkSiteRow row,
        IProgress<OperationProgress> 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<string> CreateTeamSiteAsync(
        ClientContext adminCtx,
        BulkSiteRow row,
        IProgress<OperationProgress> 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<string> CreateCommunicationSiteAsync(
        ClientContext adminCtx,
        BulkSiteRow row,
        IProgress<OperationProgress> 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<string> ParseEmails(string commaSeparated)
    {
        if (string.IsNullOrWhiteSpace(commaSeparated))
            return new List<string>();

        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:

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:

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:

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