---
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<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:**
```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`
