All checks were successful
Release zip package / release (push) Successful in 10s
Archive 5 phases (36 plans) to milestones/v1.0-phases/. Archive roadmap, requirements, and audit to milestones/. Evolve PROJECT.md with shipped state and validated requirements. Collapse ROADMAP.md to one-line milestone summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
343 lines
12 KiB
Markdown
343 lines
12 KiB
Markdown
---
|
|
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`
|