docs(10): create phase plan - 3 plans in 2 waves
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,11 @@
|
|||||||
2. A client logo imported per tenant profile is persisted as a base64 string inside the tenant's profile JSON and is not affected by other tenants' profiles
|
2. A client logo imported per tenant profile is persisted as a base64 string inside the tenant's profile JSON and is not affected by other tenants' profiles
|
||||||
3. A file larger than 512 KB or not a valid PNG/JPG is rejected at import time with an error; no invalid data reaches the JSON store
|
3. A file larger than 512 KB or not a valid PNG/JPG is rejected at import time with an error; no invalid data reaches the JSON store
|
||||||
4. `GraphUserDirectoryService.GetUsersAsync` returns all enabled member users for a tenant, following `@odata.nextLink` until exhausted, without truncating at 999
|
4. `GraphUserDirectoryService.GetUsersAsync` returns all enabled member users for a tenant, following `@odata.nextLink` until exhausted, without truncating at 999
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 10-01-PLAN.md — Logo models, BrandingRepository, BrandingService with validation/compression
|
||||||
|
- [ ] 10-02-PLAN.md — GraphUserDirectoryService with PageIterator pagination
|
||||||
|
- [ ] 10-03-PLAN.md — DI registration in App.xaml.cs and full test suite gate
|
||||||
|
|
||||||
### Phase 11: HTML Export Branding + ViewModel Integration
|
### Phase 11: HTML Export Branding + ViewModel Integration
|
||||||
**Goal**: All five HTML reports display MSP and client logos in a consistent header, and administrators can manage logos from Settings and the profile dialog without touching the View layer.
|
**Goal**: All five HTML reports display MSP and client logos in a consistent header, and administrators can manage logos from Settings and the profile dialog without touching the View layer.
|
||||||
@@ -101,7 +105,7 @@
|
|||||||
|-------|-----------|-------|--------|-----------|
|
|-------|-----------|-------|--------|-----------|
|
||||||
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
||||||
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
||||||
| 10. Branding Data Foundation | v2.2 | 0/? | Not started | — |
|
| 10. Branding Data Foundation | v2.2 | 0/3 | Planned | — |
|
||||||
| 11. HTML Export Branding + ViewModel Integration | v2.2 | 0/? | Not started | — |
|
| 11. HTML Export Branding + ViewModel Integration | v2.2 | 0/? | Not started | — |
|
||||||
| 12. Branding UI Views | v2.2 | 0/? | Not started | — |
|
| 12. Branding UI Views | v2.2 | 0/? | Not started | — |
|
||||||
| 13. User Directory ViewModel | v2.2 | 0/? | Not started | — |
|
| 13. User Directory ViewModel | v2.2 | 0/? | Not started | — |
|
||||||
|
|||||||
274
.planning/phases/10-branding-data-foundation/10-01-PLAN.md
Normal file
274
.planning/phases/10-branding-data-foundation/10-01-PLAN.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/Core/Models/LogoData.cs
|
||||||
|
- SharepointToolbox/Core/Models/BrandingSettings.cs
|
||||||
|
- SharepointToolbox/Core/Models/TenantProfile.cs
|
||||||
|
- SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs
|
||||||
|
- SharepointToolbox/Services/IBrandingService.cs
|
||||||
|
- SharepointToolbox/Services/BrandingService.cs
|
||||||
|
- SharepointToolbox.Tests/Services/BrandingServiceTests.cs
|
||||||
|
- SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BRAND-01
|
||||||
|
- BRAND-03
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "An MSP logo imported as PNG or JPG is persisted as base64 in branding.json and survives round-trip"
|
||||||
|
- "A client logo imported per tenant profile is persisted as base64 inside the profile JSON"
|
||||||
|
- "A file that is not PNG/JPG (e.g., BMP) is rejected with a descriptive error message"
|
||||||
|
- "A file larger than 512 KB is silently compressed to fit under the limit"
|
||||||
|
- "A file under 512 KB is stored without modification"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
provides: "Shared logo record with Base64 and MimeType properties"
|
||||||
|
contains: "record LogoData"
|
||||||
|
- path: "SharepointToolbox/Core/Models/BrandingSettings.cs"
|
||||||
|
provides: "MSP logo wrapper model"
|
||||||
|
contains: "LogoData? MspLogo"
|
||||||
|
- path: "SharepointToolbox/Core/Models/TenantProfile.cs"
|
||||||
|
provides: "Client logo property on existing profile model"
|
||||||
|
contains: "LogoData? ClientLogo"
|
||||||
|
- path: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
|
||||||
|
provides: "JSON persistence for BrandingSettings with write-then-replace"
|
||||||
|
contains: "SemaphoreSlim"
|
||||||
|
- path: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
provides: "Logo import with magic byte validation and auto-compression"
|
||||||
|
exports: ["ImportLogoAsync"]
|
||||||
|
- path: "SharepointToolbox.Tests/Services/BrandingServiceTests.cs"
|
||||||
|
provides: "Unit tests for validation, compression, rejection"
|
||||||
|
min_lines: 60
|
||||||
|
- path: "SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs"
|
||||||
|
provides: "Unit tests for repository round-trip"
|
||||||
|
min_lines: 30
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
to: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
|
||||||
|
via: "constructor injection"
|
||||||
|
pattern: "BrandingRepository"
|
||||||
|
- from: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
via: "return type"
|
||||||
|
pattern: "LogoData"
|
||||||
|
- from: "SharepointToolbox/Core/Models/BrandingSettings.cs"
|
||||||
|
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
via: "property type"
|
||||||
|
pattern: "LogoData\\? MspLogo"
|
||||||
|
- from: "SharepointToolbox/Core/Models/TenantProfile.cs"
|
||||||
|
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
via: "property type"
|
||||||
|
pattern: "LogoData\\? ClientLogo"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the logo storage infrastructure: models, repository, and branding service with validation/compression.
|
||||||
|
|
||||||
|
Purpose: BRAND-01, BRAND-03, BRAND-06 require models for logo data, a repository for MSP branding persistence, extension of TenantProfile for client logos, and a service that validates format (magic bytes) and auto-compresses oversized files.
|
||||||
|
|
||||||
|
Output: LogoData record, BrandingSettings model, TenantProfile extension, BrandingRepository, BrandingService (with IBrandingService interface), and comprehensive unit tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing patterns the executor needs to follow exactly. -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/AppSettings.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
public string DataFolder { get; set; } = string.Empty;
|
||||||
|
public string Lang { get; set; } = "en";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/TenantProfile.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class TenantProfile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class SettingsRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
public SettingsRepository(string filePath) { _filePath = filePath; }
|
||||||
|
public async Task<AppSettings> LoadAsync() { /* File.ReadAllTextAsync + JsonSerializer.Deserialize */ }
|
||||||
|
public async Task SaveAsync(AppSettings settings) { /* SemaphoreSlim + write-tmp + validate round-trip + File.Move */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox.Tests/Services/SettingsServiceTests.cs (test pattern):
|
||||||
|
```csharp
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class SettingsServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempFile;
|
||||||
|
public SettingsServiceTests() { _tempFile = Path.GetTempFileName(); File.Delete(_tempFile); }
|
||||||
|
public void Dispose() { if (File.Exists(_tempFile)) File.Delete(_tempFile); if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp"); }
|
||||||
|
private SettingsRepository CreateRepository() => new(_tempFile);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create logo models, BrandingRepository, and repository tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Core/Models/LogoData.cs,
|
||||||
|
SharepointToolbox/Core/Models/BrandingSettings.cs,
|
||||||
|
SharepointToolbox/Core/Models/TenantProfile.cs,
|
||||||
|
SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs,
|
||||||
|
SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: BrandingRepository.LoadAsync returns default BrandingSettings (MspLogo=null) when file does not exist
|
||||||
|
- Test 2: BrandingRepository round-trips BrandingSettings with a non-null MspLogo (Base64 + MimeType preserved)
|
||||||
|
- Test 3: BrandingRepository.SaveAsync creates directory if it does not exist
|
||||||
|
- Test 4: TenantProfile with ClientLogo serializes to JSON with camelCase "clientLogo" key and deserializes back correctly (use System.Text.Json directly)
|
||||||
|
- Test 5: TenantProfile without ClientLogo (null) serializes with clientLogo absent or null and deserializes with ClientLogo=null (forward-compatible)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `LogoData.cs` as a non-positional record with `{ get; init; }` properties (NOT positional constructor) to avoid System.Text.Json deserialization pitfall (see RESEARCH Pitfall 3):
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
public record LogoData
|
||||||
|
{
|
||||||
|
public string Base64 { get; init; } = string.Empty;
|
||||||
|
public string MimeType { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `BrandingSettings.cs`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
public class BrandingSettings
|
||||||
|
{
|
||||||
|
public LogoData? MspLogo { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Extend `TenantProfile.cs` — add ONE property: `public LogoData? ClientLogo { get; set; }`. Do NOT remove or rename any existing properties. This is additive only. ProfileRepository needs no code change — System.Text.Json handles the new nullable property automatically.
|
||||||
|
|
||||||
|
4. Create `BrandingRepository.cs` as an exact structural clone of `SettingsRepository.cs`, substituting `BrandingSettings` for `AppSettings`. Same pattern: `SemaphoreSlim(1,1)`, `File.ReadAllTextAsync`, `JsonSerializer.Deserialize<BrandingSettings>`, write-then-replace with `.tmp` file, `JsonDocument.Parse` validation, `File.Move(overwrite: true)`. Use `PropertyNameCaseInsensitive = true` for Load and `PropertyNamingPolicy = JsonNamingPolicy.CamelCase` + `WriteIndented = true` for Save. Same error handling (InvalidDataException for IO/JSON errors).
|
||||||
|
|
||||||
|
5. Write `BrandingRepositoryTests.cs` following the `SettingsServiceTests` pattern: `IDisposable`, `Path.GetTempFileName()`, cleanup of `.tmp` files, `[Trait("Category", "Unit")]`. Tests for TenantProfile serialization use `JsonSerializer` directly (no repository needed — just confirm the model serializes/deserializes with the new property).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>LogoData record, BrandingSettings model, TenantProfile.ClientLogo property, and BrandingRepository all exist. Repository round-trips BrandingSettings with MspLogo. TenantProfile with ClientLogo serializes correctly. All tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Create BrandingService with validation, compression, and tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Services/IBrandingService.cs,
|
||||||
|
SharepointToolbox/Services/BrandingService.cs,
|
||||||
|
SharepointToolbox.Tests/Services/BrandingServiceTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: ImportLogoAsync with valid PNG bytes (magic: 0x89,0x50,0x4E,0x47 + minimal valid content) returns LogoData with MimeType="image/png" and correct Base64
|
||||||
|
- Test 2: ImportLogoAsync with valid JPEG bytes (magic: 0xFF,0xD8,0xFF + minimal content) returns LogoData with MimeType="image/jpeg"
|
||||||
|
- Test 3: ImportLogoAsync with BMP bytes (magic: 0x42,0x4D) throws InvalidDataException with message containing "PNG" and "JPG"
|
||||||
|
- Test 4: ImportLogoAsync with empty file throws InvalidDataException
|
||||||
|
- Test 5: ImportLogoAsync with file under 512 KB returns Base64 matching original bytes exactly (no compression)
|
||||||
|
- Test 6: ImportLogoAsync with file over 512 KB returns LogoData where decoded bytes are <= 512 KB (compressed)
|
||||||
|
- Test 7: SaveMspLogoAsync calls BrandingRepository.SaveAsync with the logo set on BrandingSettings.MspLogo
|
||||||
|
- Test 8: ClearMspLogoAsync saves BrandingSettings with MspLogo=null
|
||||||
|
- Test 9: GetMspLogoAsync returns null when no logo is configured
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `IBrandingService.cs`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
public interface IBrandingService
|
||||||
|
{
|
||||||
|
Task<LogoData> ImportLogoAsync(string filePath);
|
||||||
|
Task SaveMspLogoAsync(LogoData logo);
|
||||||
|
Task ClearMspLogoAsync();
|
||||||
|
Task<LogoData?> GetMspLogoAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Note: `ImportLogoAsync` is a pure validation+encoding function. It reads the file, validates magic bytes, compresses if needed, and returns `LogoData`. It does NOT persist anything. The caller (ViewModel in Phase 11) decides whether to save as MSP logo or client logo.
|
||||||
|
|
||||||
|
2. Create `BrandingService.cs`:
|
||||||
|
- Constructor takes `BrandingRepository` (same pattern as `SettingsService` taking `SettingsRepository`).
|
||||||
|
- `ImportLogoAsync(string filePath)`:
|
||||||
|
a. Read all bytes via `File.ReadAllBytesAsync`.
|
||||||
|
b. Detect MIME type from magic bytes: PNG signature `0x89,0x50,0x4E,0x47` (first 4 bytes), JPEG signature `0xFF,0xD8,0xFF` (first 3 bytes). If neither matches, throw `InvalidDataException("File format is not PNG or JPG. Only PNG and JPG are accepted.")`.
|
||||||
|
c. If bytes.Length > 512 * 1024, call `CompressToLimit(bytes, mimeType, 512 * 1024)`.
|
||||||
|
d. Return `new LogoData { Base64 = Convert.ToBase64String(bytes), MimeType = mimeType }`.
|
||||||
|
- `CompressToLimit` private static method: Use `System.Drawing.Bitmap` to resize to max 300x300px (proportional scaling) and re-encode at quality 75. Use `System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders()` to find the codec matching the MIME type. Use `EncoderParameters` with `Encoder.Quality` set to 75L. If still over limit after first pass, reduce to 200x200 and quality 50. Return the compressed bytes.
|
||||||
|
- `SaveMspLogoAsync(LogoData logo)`: Load settings from repo, set `MspLogo = logo`, save back.
|
||||||
|
- `ClearMspLogoAsync()`: Load settings, set `MspLogo = null`, save back.
|
||||||
|
- `GetMspLogoAsync()`: Load settings, return `MspLogo` (may be null).
|
||||||
|
|
||||||
|
3. Create `BrandingServiceTests.cs`:
|
||||||
|
- Use `[Trait("Category", "Unit")]` and `IDisposable` pattern.
|
||||||
|
- For magic byte tests: create small byte arrays with correct headers. For PNG, use the 8-byte PNG signature (`0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A`) followed by minimal IHDR+IEND chunks to make a valid 1x1 PNG. For JPEG, use `0xFF,0xD8,0xFF,0xE0` + minimal JFIF header + `0xFF,0xD9` (EOI). Write these to temp files and call `ImportLogoAsync`.
|
||||||
|
- For compression test: generate a valid PNG/JPEG that exceeds 512 KB (e.g., create a 400x400 bitmap filled with random pixels, save as PNG to a temp file, verify it exceeds 512 KB, then call `ImportLogoAsync` and verify result decodes to <= 512 KB).
|
||||||
|
- For SaveMspLogoAsync/ClearMspLogoAsync/GetMspLogoAsync: use real `BrandingRepository` with temp file (same pattern as `SettingsServiceTests`).
|
||||||
|
- Do NOT mock BrandingRepository — the existing test pattern in this codebase uses real file I/O with temp files.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>BrandingService validates PNG/JPG via magic bytes, rejects other formats with descriptive error, auto-compresses files over 512 KB, and provides MSP logo CRUD. All tests pass including round-trip through repository.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test --filter "FullyQualifiedName~Branding" --no-build
|
||||||
|
dotnet test --filter "FullyQualifiedName~ProfileService" --no-build
|
||||||
|
```
|
||||||
|
All three commands must succeed with zero failures. The ProfileServiceTests confirm TenantProfile changes do not break existing profile persistence.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- LogoData record exists with Base64 and MimeType init properties
|
||||||
|
- BrandingSettings class exists with nullable MspLogo property
|
||||||
|
- TenantProfile has nullable ClientLogo property (additive, no breaking changes)
|
||||||
|
- BrandingRepository persists BrandingSettings to JSON with write-then-replace safety
|
||||||
|
- BrandingService validates magic bytes (PNG/JPG only), auto-compresses > 512 KB, and provides MSP logo CRUD
|
||||||
|
- All existing tests continue to pass (no regressions from TenantProfile extension)
|
||||||
|
- New tests cover: repository round-trip, format validation, compression, rejection, CRUD
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/10-branding-data-foundation/10-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
235
.planning/phases/10-branding-data-foundation/10-02-PLAN.md
Normal file
235
.planning/phases/10-branding-data-foundation/10-02-PLAN.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/Core/Models/GraphDirectoryUser.cs
|
||||||
|
- SharepointToolbox/Services/IGraphUserDirectoryService.cs
|
||||||
|
- SharepointToolbox/Services/GraphUserDirectoryService.cs
|
||||||
|
- SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "GetUsersAsync returns all enabled member users following @odata.nextLink until exhausted"
|
||||||
|
- "GetUsersAsync respects CancellationToken and stops iteration when cancelled"
|
||||||
|
- "Each returned user includes DisplayName, UserPrincipalName, Mail, Department, and JobTitle"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs"
|
||||||
|
provides: "Result record for directory enumeration"
|
||||||
|
contains: "record GraphDirectoryUser"
|
||||||
|
- path: "SharepointToolbox/Services/IGraphUserDirectoryService.cs"
|
||||||
|
provides: "Interface for directory enumeration"
|
||||||
|
exports: ["GetUsersAsync"]
|
||||||
|
- path: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
provides: "PageIterator-based Graph user enumeration"
|
||||||
|
contains: "PageIterator"
|
||||||
|
- path: "SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs"
|
||||||
|
provides: "Unit tests for directory service"
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
to: "SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs"
|
||||||
|
via: "constructor injection"
|
||||||
|
pattern: "GraphClientFactory"
|
||||||
|
- from: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
to: "Microsoft.Graph PageIterator"
|
||||||
|
via: "SDK pagination"
|
||||||
|
pattern: "PageIterator<User, UserCollectionResponse>"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the Graph user directory service for paginated tenant user enumeration.
|
||||||
|
|
||||||
|
Purpose: Phase 13 (User Directory ViewModel) needs a service that enumerates all enabled member users from a tenant via Microsoft Graph with pagination. This plan builds the infrastructure service and its tests.
|
||||||
|
|
||||||
|
Output: GraphDirectoryUser model, IGraphUserDirectoryService interface, GraphUserDirectoryService implementation with PageIterator, and unit tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing Graph service pattern to follow. -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/IGraphUserSearchService.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public interface IGraphUserSearchService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
string query,
|
||||||
|
int maxResults = 10,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GraphUserResult(string DisplayName, string UserPrincipalName, string? Mail);
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/GraphUserSearchService.cs:
|
||||||
|
```csharp
|
||||||
|
public class GraphUserSearchService : IGraphUserSearchService
|
||||||
|
{
|
||||||
|
private readonly GraphClientFactory _graphClientFactory;
|
||||||
|
|
||||||
|
public GraphUserSearchService(GraphClientFactory graphClientFactory)
|
||||||
|
{
|
||||||
|
_graphClientFactory = graphClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
|
||||||
|
string clientId, string query, int maxResults = 10, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||||
|
var response = await graphClient.Users.GetAsync(config =>
|
||||||
|
{
|
||||||
|
config.QueryParameters.Filter = $"startsWith(displayName,'{escapedQuery}')...";
|
||||||
|
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail" };
|
||||||
|
config.QueryParameters.Top = maxResults;
|
||||||
|
config.Headers.Add("ConsistencyLevel", "eventual");
|
||||||
|
config.QueryParameters.Count = true;
|
||||||
|
}, ct);
|
||||||
|
// ...map response.Value to GraphUserResult list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
|
||||||
|
```csharp
|
||||||
|
public class GraphClientFactory
|
||||||
|
{
|
||||||
|
private readonly MsalClientFactory _msalFactory;
|
||||||
|
public GraphClientFactory(MsalClientFactory msalFactory) { _msalFactory = msalFactory; }
|
||||||
|
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct) { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create GraphDirectoryUser model and IGraphUserDirectoryService interface</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Core/Models/GraphDirectoryUser.cs,
|
||||||
|
SharepointToolbox/Services/IGraphUserDirectoryService.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- GraphDirectoryUser is a positional record with DisplayName (string), UserPrincipalName (string), Mail (string?), Department (string?), JobTitle (string?)
|
||||||
|
- IGraphUserDirectoryService declares GetUsersAsync(string clientId, IProgress<int>? progress, CancellationToken ct) returning Task<IReadOnlyList<GraphDirectoryUser>>
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `GraphDirectoryUser.cs` in `Core/Models/`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
public record GraphDirectoryUser(
|
||||||
|
string DisplayName,
|
||||||
|
string UserPrincipalName,
|
||||||
|
string? Mail,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle);
|
||||||
|
```
|
||||||
|
This is a positional record (fine here since it's never JSON-deserialized — it's only constructed in code from Graph SDK User objects).
|
||||||
|
|
||||||
|
2. Create `IGraphUserDirectoryService.cs` in `Services/`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
public interface IGraphUserDirectoryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
IProgress<int>? progress = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The `IProgress<int>` parameter reports the running count of users fetched so far — Phase 13's ViewModel will use this to show "Loading... X users" feedback. It's optional (null = no reporting).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||||
|
</verify>
|
||||||
|
<done>GraphDirectoryUser record and IGraphUserDirectoryService interface exist and compile without warnings.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Implement GraphUserDirectoryService with PageIterator and tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Services/GraphUserDirectoryService.cs,
|
||||||
|
SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: GetUsersAsync with mocked GraphClientFactory returns mapped GraphDirectoryUser records with all 5 fields
|
||||||
|
- Test 2: GetUsersAsync reports progress via IProgress<int> with incrementing user count
|
||||||
|
- Test 3: GetUsersAsync with cancelled token throws OperationCanceledException or returns partial results
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `GraphUserDirectoryService.cs`:
|
||||||
|
- Constructor takes `GraphClientFactory` (same pattern as `GraphUserSearchService`).
|
||||||
|
- `GetUsersAsync` implementation:
|
||||||
|
a. Get `GraphServiceClient` via `_graphClientFactory.CreateClientAsync(clientId, ct)`.
|
||||||
|
b. Call `graphClient.Users.GetAsync(config => { ... }, ct)` with:
|
||||||
|
- `config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'"` — standard equality filter, does NOT require ConsistencyLevel: eventual (unlike GraphUserSearchService which uses startsWith). Do NOT add ConsistencyLevel header. Do NOT add $count.
|
||||||
|
- `config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail", "department", "jobTitle" }`
|
||||||
|
- `config.QueryParameters.Top = 999`
|
||||||
|
c. If response is null, return empty list.
|
||||||
|
d. Create `PageIterator<User, UserCollectionResponse>.CreatePageIterator(graphClient, response, callback)`.
|
||||||
|
e. In the callback:
|
||||||
|
- Check `ct.IsCancellationRequested` — if true, `return false` to stop iteration (see RESEARCH Pitfall 2).
|
||||||
|
- Map User to GraphDirectoryUser: `new GraphDirectoryUser(user.DisplayName ?? user.UserPrincipalName ?? string.Empty, user.UserPrincipalName ?? string.Empty, user.Mail, user.Department, user.JobTitle)`.
|
||||||
|
- Add to results list.
|
||||||
|
- Report progress: `progress?.Report(results.Count)`.
|
||||||
|
- Return true to continue.
|
||||||
|
f. Call `await pageIterator.IterateAsync(ct)`.
|
||||||
|
g. Return results as `IReadOnlyList<GraphDirectoryUser>`.
|
||||||
|
- Add a comment on the filter line: `// Pending real-tenant verification — see STATE.md pending todos`
|
||||||
|
|
||||||
|
2. Create `GraphUserDirectoryServiceTests.cs`:
|
||||||
|
- Use `[Trait("Category", "Unit")]`.
|
||||||
|
- Testing PageIterator with mocks is complex because `PageIterator` requires a real `GraphServiceClient`. Instead, test at a higher level:
|
||||||
|
a. Create a mock `GraphClientFactory` using Moq that returns a mock `GraphServiceClient`.
|
||||||
|
b. For the basic mapping test: mock `graphClient.Users.GetAsync()` to return a `UserCollectionResponse` with a list of test `User` objects (no `@odata.nextLink` = single page). Verify the returned `GraphDirectoryUser` list has correct field mapping.
|
||||||
|
c. For the progress test: same setup, verify `IProgress<int>.Report` is called with incrementing counts.
|
||||||
|
d. For cancellation: use a pre-cancelled `CancellationTokenSource`. The `GetAsync` call should throw `OperationCanceledException` or the callback should detect cancellation.
|
||||||
|
- If mocking `GraphServiceClient.Users.GetAsync` proves too complex with the Graph SDK's request builder pattern, mark the test with `[Fact(Skip = "Requires integration test with real Graph client")]` and add a comment explaining why. The critical thing is the test FILE exists with the intent documented.
|
||||||
|
- Focus on what IS testable without a real Graph endpoint: the mapping logic. Consider extracting a static `MapUser(User user)` method and testing that directly.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>GraphUserDirectoryService exists with PageIterator pagination, cancellation support via callback check, progress reporting, and correct filter (no ConsistencyLevel). Tests verify mapping logic and exist for pagination/cancellation scenarios.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test --filter "FullyQualifiedName~GraphUserDirectoryService" --no-build
|
||||||
|
```
|
||||||
|
Both commands must succeed. No warnings, no test failures.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- GraphDirectoryUser record has all 5 fields (DisplayName, UPN, Mail, Department, JobTitle)
|
||||||
|
- IGraphUserDirectoryService interface declares GetUsersAsync with clientId, progress, and cancellation
|
||||||
|
- GraphUserDirectoryService uses PageIterator for pagination, checks cancellation in callback, reports progress
|
||||||
|
- Filter is "accountEnabled eq true and userType eq 'Member'" WITHOUT ConsistencyLevel header
|
||||||
|
- Tests exist and pass for mapping logic; pagination/cancellation tests are either passing or skipped with clear justification
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/10-branding-data-foundation/10-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
145
.planning/phases/10-branding-data-foundation/10-03-PLAN.md
Normal file
145
.planning/phases/10-branding-data-foundation/10-03-PLAN.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 10-01
|
||||||
|
- 10-02
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/App.xaml.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BRAND-01
|
||||||
|
- BRAND-03
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "BrandingRepository, BrandingService, and GraphUserDirectoryService are resolved by DI without runtime errors"
|
||||||
|
- "The full test suite passes including all new and existing tests"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/App.xaml.cs"
|
||||||
|
provides: "DI registration for Phase 10 services"
|
||||||
|
contains: "BrandingRepository"
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/App.xaml.cs"
|
||||||
|
to: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
|
||||||
|
via: "AddSingleton registration"
|
||||||
|
pattern: "BrandingRepository.*branding\\.json"
|
||||||
|
- from: "SharepointToolbox/App.xaml.cs"
|
||||||
|
to: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
via: "AddSingleton registration"
|
||||||
|
pattern: "AddSingleton<BrandingService>"
|
||||||
|
- from: "SharepointToolbox/App.xaml.cs"
|
||||||
|
to: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
via: "AddTransient registration"
|
||||||
|
pattern: "IGraphUserDirectoryService.*GraphUserDirectoryService"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Register all Phase 10 services in the DI container and run the full test suite to confirm no regressions.
|
||||||
|
|
||||||
|
Purpose: Without DI registration, none of the new services are available at runtime. This plan wires BrandingRepository, BrandingService, and GraphUserDirectoryService into App.xaml.cs following established patterns.
|
||||||
|
|
||||||
|
Output: Updated App.xaml.cs with Phase 10 DI registrations. Full test suite green.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-01-SUMMARY.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- DI registration pattern from App.xaml.cs (lines 73-163). -->
|
||||||
|
|
||||||
|
From SharepointToolbox/App.xaml.cs:
|
||||||
|
```csharp
|
||||||
|
private static void RegisterServices(HostBuilderContext ctx, IServiceCollection services)
|
||||||
|
{
|
||||||
|
var appData = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"SharepointToolbox");
|
||||||
|
services.AddSingleton(_ => new ProfileRepository(Path.Combine(appData, "profiles.json")));
|
||||||
|
services.AddSingleton(_ => new SettingsRepository(Path.Combine(appData, "settings.json")));
|
||||||
|
services.AddSingleton<MsalClientFactory>();
|
||||||
|
services.AddSingleton<SessionManager>();
|
||||||
|
// ... more registrations ...
|
||||||
|
services.AddSingleton<GraphClientFactory>();
|
||||||
|
// ... more registrations ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From 10-RESEARCH.md Pattern 7:
|
||||||
|
```csharp
|
||||||
|
// Phase 10: Branding Data Foundation
|
||||||
|
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
|
||||||
|
services.AddSingleton<IBrandingService, BrandingService>();
|
||||||
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Register Phase 10 services in DI and run full test suite</name>
|
||||||
|
<files>SharepointToolbox/App.xaml.cs</files>
|
||||||
|
<action>
|
||||||
|
1. Open `SharepointToolbox/App.xaml.cs` and locate the `RegisterServices` method.
|
||||||
|
|
||||||
|
2. Add a new section comment and three registrations AFTER the existing `SettingsRepository` registration (around line 79) and BEFORE the `MsalClientFactory` line. Place them logically with the other repository/service registrations:
|
||||||
|
```csharp
|
||||||
|
// Phase 10: Branding Data Foundation
|
||||||
|
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
|
||||||
|
services.AddSingleton<IBrandingService, BrandingService>();
|
||||||
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the necessary `using` statements at the top of the file if not already present:
|
||||||
|
- `using SharepointToolbox.Infrastructure.Persistence;` (likely already present for ProfileRepository/SettingsRepository)
|
||||||
|
- `using SharepointToolbox.Services;` (likely already present for other service registrations)
|
||||||
|
|
||||||
|
4. Rationale for lifetimes per RESEARCH:
|
||||||
|
- `BrandingRepository`: Singleton — single file, shared SemaphoreSlim lock (same as ProfileRepository and SettingsRepository).
|
||||||
|
- `BrandingService` (as `IBrandingService`): Singleton — stateless after construction, depends on singleton repository.
|
||||||
|
- `GraphUserDirectoryService` (as `IGraphUserDirectoryService`): Transient — stateless, per-call usage, different tenants.
|
||||||
|
|
||||||
|
5. Build and run the full test suite to confirm zero regressions:
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build --no-restore -warnaserror && dotnet test</automated>
|
||||||
|
</verify>
|
||||||
|
<done>App.xaml.cs has Phase 10 DI registrations. Full build succeeds with zero warnings. Full test suite passes with zero failures.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
Both must succeed. Zero warnings, zero test failures. This is the phase gate.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- App.xaml.cs registers BrandingRepository (Singleton, branding.json), IBrandingService/BrandingService (Singleton), IGraphUserDirectoryService/GraphUserDirectoryService (Transient)
|
||||||
|
- Full build passes with -warnaserror
|
||||||
|
- Full test suite passes (all existing + all new tests)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/10-branding-data-foundation/10-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user