docs(phase-10): research branding data foundation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
530
.planning/phases/10-branding-data-foundation/10-RESEARCH.md
Normal file
530
.planning/phases/10-branding-data-foundation/10-RESEARCH.md
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
# Phase 10: Branding Data Foundation - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-08
|
||||||
|
**Domain:** C# WPF / .NET 10 — JSON persistence, image validation, Microsoft Graph SDK pagination
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 10 is a pure infrastructure phase: no UI, no new NuGet packages. It introduces three new models (`LogoData`, `BrandingSettings`, plus extends `TenantProfile`), two repositories (`BrandingRepository` mirroring `SettingsRepository`), two services (`BrandingService` for validation/compression, `GraphUserDirectoryService` for paginated Graph enumeration), and registration of those in `App.xaml.cs`. All work is additive — nothing in the existing stack is removed or renamed.
|
||||||
|
|
||||||
|
The central technical challenge splits into two independent tracks:
|
||||||
|
1. **Logo storage track:** Image format detection from magic bytes, silent compression using `System.Drawing.Common` (available via WPF's `PresentationCore`/`System.Drawing.Common` BCL subset on net10.0-windows), base64 serialization in JSON.
|
||||||
|
2. **Graph directory track:** `PageIterator<User, UserCollectionResponse>` from Microsoft.Graph 5.x following `@odata.nextLink` until exhausted, with `CancellationToken` threading throughout.
|
||||||
|
|
||||||
|
Both tracks fit the existing patterns precisely. The repository uses `SemaphoreSlim(1,1)` + write-then-move. The Graph service clones `GraphUserSearchService` structure while substituting `PageIterator` for a one-shot `GetAsync`. No configuration, no new packages, no breaking changes.
|
||||||
|
|
||||||
|
**Primary recommendation:** Implement in order — models first, then repository, then services, then DI registration, then update `ProfileManagementViewModel.DeleteAsync` warning message. Tests mirror the `SettingsServiceTests` and `ProfileServiceTests` patterns already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
| Decision | Value |
|
||||||
|
|---|---|
|
||||||
|
| Logo storage format | Base64 strings in JSON (not file paths) |
|
||||||
|
| MSP logo location | `BrandingSettings.cs` model → `branding.json` |
|
||||||
|
| Client logo location | On `TenantProfile` model (per-tenant) |
|
||||||
|
| File path after import | Discarded — only base64 persists |
|
||||||
|
| SVG support | Rejected (XSS risk) — PNG/JPG only |
|
||||||
|
| User directory service | New `GraphUserDirectoryService`, separate from `GraphUserSearchService` |
|
||||||
|
| Directory auto-load | No — explicit "Load Directory" button required |
|
||||||
|
| New NuGet packages | None — existing stack covers everything |
|
||||||
|
| Export service signature | Optional `ReportBranding? branding = null` parameter on existing export methods |
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- No discretion areas defined for Phase 10 — all decisions locked.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
- Logo preview in Settings UI (Phase 12)
|
||||||
|
- Auto-pull client logo from Entra branding API (Phase 11/12)
|
||||||
|
- Report header layout with logos side-by-side (Phase 11)
|
||||||
|
- "Load Directory" button placement decision (Phase 14)
|
||||||
|
- Session-scoped directory cache (UDIR-F01, deferred)
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| BRAND-01 | User can import an MSP logo in application settings (global, persisted across sessions) | `BrandingSettings` model + `BrandingRepository` (mirrors `SettingsRepository`) + `BrandingService.ImportLogoAsync` |
|
||||||
|
| BRAND-03 | User can import a client logo per tenant profile | `LogoData? ClientLogo` property on `TenantProfile` + `ProfileRepository` already handles serialization; `BrandingService.ImportLogoAsync` reused |
|
||||||
|
| BRAND-06 | Logo import validates format (PNG/JPG) and enforces 512 KB size limit | Magic byte detection (PNG: `89 50 4E 47`, JPEG: `FF D8 FF`) + auto-compress via `System.Drawing`/`BitmapEncoder` if > 512 KB |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (all already present — zero new installs)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| `System.Text.Json` | BCL (net10.0) | JSON serialization of models | Already used in all repositories |
|
||||||
|
| `System.Drawing.Common` | BCL (net10.0-windows) | Image load, resize, re-encode for compression | Available on Windows via `UseWPF=true`; no extra package |
|
||||||
|
| `Microsoft.Graph` | 5.74.0 (already in csproj) | Graph SDK for user enumeration | Already used by `GraphUserSearchService` |
|
||||||
|
| `Microsoft.Identity.Client` | 4.83.3 (already in csproj) | Token acquisition via `GraphClientFactory` | Already used |
|
||||||
|
| `CommunityToolkit.Mvvm` | 8.4.2 (already in csproj) | `[ObservableProperty]` for ViewModels — not used in Phase 10 directly, but referenced by `ProfileManagementViewModel` | Already used |
|
||||||
|
|
||||||
|
### No New Packages
|
||||||
|
All capabilities are covered by the existing stack. Confirmed in CONTEXT.md locked decisions and csproj inspection.
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure (new files only)
|
||||||
|
|
||||||
|
```
|
||||||
|
SharepointToolbox/
|
||||||
|
├── Core/
|
||||||
|
│ └── Models/
|
||||||
|
│ ├── LogoData.cs -- record LogoData(string Base64, string MimeType)
|
||||||
|
│ └── BrandingSettings.cs -- class BrandingSettings { LogoData? MspLogo; }
|
||||||
|
├── Infrastructure/
|
||||||
|
│ └── Persistence/
|
||||||
|
│ └── BrandingRepository.cs -- clone of SettingsRepository<BrandingSettings>
|
||||||
|
├── Services/
|
||||||
|
│ ├── IBrandingService.cs -- ImportLogoAsync, ClearLogoAsync
|
||||||
|
│ ├── BrandingService.cs -- validates magic bytes, compresses, returns LogoData
|
||||||
|
│ ├── IGraphUserDirectoryService.cs -- GetUsersAsync with PageIterator
|
||||||
|
│ └── GraphUserDirectoryService.cs -- PageIterator pagination
|
||||||
|
|
||||||
|
SharepointToolbox.Tests/
|
||||||
|
└── Services/
|
||||||
|
├── BrandingServiceTests.cs -- magic bytes, compression, rejection
|
||||||
|
└── GraphUserDirectoryServiceTests.cs -- pagination (mocked PageIterator or direct list)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Repository (write-then-move with SemaphoreSlim)
|
||||||
|
|
||||||
|
Exact clone of `SettingsRepository` with `BrandingSettings` substituted for `AppSettings`. No deviations.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs (existing)
|
||||||
|
public class BrandingRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
public async Task<BrandingSettings> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
return new BrandingSettings();
|
||||||
|
// ... File.ReadAllTextAsync + JsonSerializer.Deserialize<BrandingSettings> ...
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(BrandingSettings settings)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(settings,
|
||||||
|
new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
// ... write to tmp, validate round-trip, File.Move(tmp, _filePath, overwrite: true) ...
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: LogoData record — shared by MSP and client logos
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: CONTEXT.md §1 Logo Metadata Model
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public record LogoData(string Base64, string MimeType);
|
||||||
|
// MimeType is "image/png" or "image/jpeg" — determined at import time from magic bytes
|
||||||
|
// Usage in HTML: $"data:{MimeType};base64,{Base64}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: BrandingSettings model
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class BrandingSettings
|
||||||
|
{
|
||||||
|
public LogoData? MspLogo { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: TenantProfile extension
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Extend existing TenantProfile — additive, no breaking change
|
||||||
|
public class TenantProfile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public LogoData? ClientLogo { get; set; } // NEW — nullable, ignored when null in JSON
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProfileRepository` needs no code change — `System.Text.Json` serializes the new nullable property automatically. Existing profiles JSON without `clientLogo` deserializes with `null` (forward-compatible).
|
||||||
|
|
||||||
|
### Pattern 5: Magic byte validation + compression in BrandingService
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: CONTEXT.md §2 Logo Validation & Compression
|
||||||
|
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47 };
|
||||||
|
private static readonly byte[] JpegSignature = { 0xFF, 0xD8, 0xFF };
|
||||||
|
|
||||||
|
private static string? DetectMimeType(byte[] header)
|
||||||
|
{
|
||||||
|
if (header.Length >= 4 && header.Take(4).SequenceEqual(PngSignature)) return "image/png";
|
||||||
|
if (header.Length >= 3 && header.Take(3).SequenceEqual(JpegSignature)) return "image/jpeg";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LogoData> ImportLogoAsync(string filePath)
|
||||||
|
{
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||||
|
var mimeType = DetectMimeType(bytes)
|
||||||
|
?? throw new InvalidDataException("File format is not PNG or JPG. Only PNG and JPG are accepted.");
|
||||||
|
|
||||||
|
if (bytes.Length > 512 * 1024)
|
||||||
|
bytes = CompressToLimit(bytes, mimeType, maxBytes: 512 * 1024);
|
||||||
|
|
||||||
|
return new LogoData(Convert.ToBase64String(bytes), mimeType);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For compression, use `System.Drawing.Bitmap` (available on net10.0-windows) to resize to max 300×300px and re-encode at reduced quality using `System.Drawing.Imaging.ImageCodecInfo`/`EncoderParameters`. Keep original format.
|
||||||
|
|
||||||
|
### Pattern 6: GraphUserDirectoryService with PageIterator
|
||||||
|
|
||||||
|
Microsoft.Graph 5.x includes `PageIterator<TEntity, TCollectionPage>` in `Microsoft.Graph.Core`. Pattern from Graph SDK docs:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: Microsoft.Graph 5.x SDK — PageIterator pattern
|
||||||
|
public async Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||||
|
var results = new List<GraphDirectoryUser>();
|
||||||
|
|
||||||
|
var response = await graphClient.Users.GetAsync(config =>
|
||||||
|
{
|
||||||
|
config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'";
|
||||||
|
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail", "department", "jobTitle" };
|
||||||
|
config.QueryParameters.Top = 999;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
if (response is null) return results;
|
||||||
|
|
||||||
|
var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
|
||||||
|
graphClient,
|
||||||
|
response,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
results.Add(new GraphDirectoryUser(
|
||||||
|
user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
|
||||||
|
user.UserPrincipalName ?? string.Empty,
|
||||||
|
user.Mail,
|
||||||
|
user.Department,
|
||||||
|
user.JobTitle));
|
||||||
|
return true; // continue iteration
|
||||||
|
});
|
||||||
|
|
||||||
|
await pageIterator.IterateAsync(ct);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageIterator` requires `Microsoft.Graph.Core` which is a transitive dependency of `Microsoft.Graph` 5.x — already present.
|
||||||
|
|
||||||
|
**No `ConsistencyLevel: eventual` needed** for the `$filter` query with `accountEnabled` and `userType` — these are standard properties, not advanced queries requiring `$count`. (Unlike the search service which uses `startsWith` and requires `ConsistencyLevel`.)
|
||||||
|
|
||||||
|
### Pattern 7: DI registration (App.xaml.cs)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Phase 10: Branding Data Foundation
|
||||||
|
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
|
||||||
|
services.AddSingleton<BrandingService>();
|
||||||
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
`BrandingRepository` is Singleton (same rationale as `ProfileRepository` and `SettingsRepository` — single file, shared lock). `BrandingService` is Singleton (stateless after construction, depends on singleton repository). `GraphUserDirectoryService` is Transient (per-tenant call, stateless).
|
||||||
|
|
||||||
|
### Pattern 8: ProfileManagementViewModel deletion message update
|
||||||
|
|
||||||
|
In `ProfileManagementViewModel.DeleteAsync()`, the existing confirmation flow has no dialog — it directly calls `_profileService.DeleteProfileAsync`. The update per CONTEXT.md is to augment the confirmation message (when that dialog exists) to mention logo removal. However, Phase 10 does not add a confirmation dialog — that is the caller's concern (View layer, Phase 12). The ViewModel update is to expose information about whether a profile has a logo, enabling Phase 12's View to conditionally show the warning.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Add a computed property to support the deletion warning in Phase 12
|
||||||
|
// This is the minimal Phase 10 change:
|
||||||
|
// TenantProfile.ClientLogo != null → the confirmation dialog (Phase 12) reads this
|
||||||
|
```
|
||||||
|
|
||||||
|
The actual deletion behavior is unchanged: deleting the profile JSON entry automatically drops the embedded `clientLogo` field. No orphaned files exist.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Do not store the file path in JSON** — only base64 + MIME type. File path is discarded immediately after reading bytes.
|
||||||
|
- **Do not use file extension for format detection** — always read magic bytes from the byte array.
|
||||||
|
- **Do not use `$search` or `$count` on the directory query** — `PageIterator` with `$filter=accountEnabled eq true and userType eq 'Member'` does not require `ConsistencyLevel: eventual`.
|
||||||
|
- **Do not create a new interface for BrandingRepository** — `SettingsRepository` has no interface either; only services get interfaces.
|
||||||
|
- **Do not add `[ObservableProperty]` to `LogoData`** — it is a plain record used in persistence layer; ViewModel bindings come in Phase 11-12.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| JSON pagination follow-up | Manual `@odata.nextLink` string parsing loop | `PageIterator<User, UserCollectionResponse>` | SDK handles retry, null checks, async iteration natively |
|
||||||
|
| Image format detection | File extension check | Magic byte read on first 4 bytes | Extensions are user-controlled and unreliable |
|
||||||
|
| Atomic file write | Direct `File.WriteAllText` | Write to `.tmp`, validate, `File.Move(overwrite:true)` | Crash during write leaves corrupted JSON; pattern already proven in all repos |
|
||||||
|
| Concurrency guard | `lock(obj)` | `SemaphoreSlim(1,1)` | Async-safe; `lock` cannot be awaited |
|
||||||
|
| Base64 encoding | Manual byte-to-char loop | `Convert.ToBase64String(bytes)` | BCL, zero allocation path, no edge cases |
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: `System.Drawing` availability on net10.0-windows
|
||||||
|
**What goes wrong:** `System.Drawing.Common` is available on Windows (the project already targets `net10.0-windows` with `UseWPF=true`) but would throw `PlatformNotSupportedException` on Linux/macOS runtimes.
|
||||||
|
**Why it happens:** .NET 6+ restricted `System.Drawing.Common` to Windows-only by default.
|
||||||
|
**How to avoid:** This project is Windows-only (WinExe, UseWPF=true) so no risk. No guard needed.
|
||||||
|
**Warning signs:** CI on Linux — not applicable here.
|
||||||
|
|
||||||
|
### Pitfall 2: `PageIterator.IterateAsync` does not accept `CancellationToken` directly in Graph SDK 5.x
|
||||||
|
**What goes wrong:** `PageIterator.IterateAsync()` in Microsoft.Graph 5.x overloads — the token must be passed when calling `CreatePageIterator`, and the iteration callback must check cancellation manually or the token goes to `IterateAsync(ct)` if the overload exists.
|
||||||
|
**Why it happens:** API surface changed between SDK versions.
|
||||||
|
**How to avoid:** Check token inside the callback: `if (ct.IsCancellationRequested) return false;` stops iteration. Also pass `ct` to the initial `GetAsync` call.
|
||||||
|
**Warning signs:** Long-running enumeration that ignores cancellation requests.
|
||||||
|
|
||||||
|
### Pitfall 3: Deserialization of `LogoData` record with `System.Text.Json`
|
||||||
|
**What goes wrong:** C# records with positional constructors may not deserialize correctly with `System.Text.Json` unless the property names match constructor parameter names exactly (case-insensitive with `PropertyNameCaseInsensitive = true`) or a `[JsonConstructor]` attribute is present.
|
||||||
|
**Why it happens:** Positional record constructor parameters are `base64` and `mimeType` (camelCase) while JSON uses `PropertyNamingPolicy.CamelCase`.
|
||||||
|
**How to avoid:** Use a class with `{ get; set; }` properties OR add `[JsonConstructor]` to the positional record constructor. Simpler: make `LogoData` a class with init setters or a non-positional record with `{ get; init; }` properties.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// SAFE version — class-style record with init setters:
|
||||||
|
public record LogoData
|
||||||
|
{
|
||||||
|
public string Base64 { get; init; } = string.Empty;
|
||||||
|
public string MimeType { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pitfall 4: Large base64 string bloating profiles.json
|
||||||
|
**What goes wrong:** A 512 KB logo becomes ~682 KB of base64 text. Per-profile, this is manageable. However, `ProfileRepository.LoadAsync` loads ALL profiles at once — 20 tenants with logos = ~14 MB in memory per load.
|
||||||
|
**Why it happens:** All profiles are stored in a single JSON array.
|
||||||
|
**How to avoid:** Phase 10 does not address this (deferred); the 512 KB cap keeps it bounded. Document as known limitation.
|
||||||
|
**Warning signs:** Not a Phase 10 concern — flag for future phases if profile count grows large.
|
||||||
|
|
||||||
|
### Pitfall 5: `File.Move` with `overwrite: true` not available on all .NET versions
|
||||||
|
**What goes wrong:** `File.Move(src, dst, overwrite: true)` was added in .NET 3.0. On older frameworks this throws.
|
||||||
|
**Why it happens:** Legacy API surface.
|
||||||
|
**How to avoid:** Not applicable — project targets net10.0. Use freely.
|
||||||
|
|
||||||
|
### Pitfall 6: Graph $filter without ConsistencyLevel on advanced queries
|
||||||
|
**What goes wrong:** The search service uses `startsWith()` which requires `ConsistencyLevel: eventual + $count=true`. If the directory service accidentally includes `$count` or `$search`, it needs the header too.
|
||||||
|
**Why it happens:** Copy-paste from `GraphUserSearchService` without removing the `ConsistencyLevel` header.
|
||||||
|
**How to avoid:** The directory filter `accountEnabled eq true and userType eq 'Member'` is a standard equality filter — does NOT require `ConsistencyLevel: eventual`. Do not copy the header from `GraphUserSearchService`.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Magic Byte Detection
|
||||||
|
```csharp
|
||||||
|
// Source: CONTEXT.md §2 Logo Validation; confirmed against PNG/JPEG specs
|
||||||
|
private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 };
|
||||||
|
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };
|
||||||
|
|
||||||
|
private static string? DetectMimeType(ReadOnlySpan<byte> header)
|
||||||
|
{
|
||||||
|
if (header.Length >= 4 && header[..4].SequenceEqual(PngMagic)) return "image/png";
|
||||||
|
if (header.Length >= 3 && header[..3].SequenceEqual(JpegMagic)) return "image/jpeg";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compression via System.Drawing (net10.0-windows)
|
||||||
|
```csharp
|
||||||
|
// Source: BCL System.Drawing.Common — Windows-only, safe here
|
||||||
|
private static byte[] CompressImage(byte[] original, string mimeType, int maxBytes)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(original);
|
||||||
|
using var bitmap = new System.Drawing.Bitmap(ms);
|
||||||
|
|
||||||
|
// Scale down proportionally to max 300px
|
||||||
|
int w = bitmap.Width, h = bitmap.Height;
|
||||||
|
if (w > 300 || h > 300)
|
||||||
|
{
|
||||||
|
double scale = Math.Min(300.0 / w, 300.0 / h);
|
||||||
|
w = (int)(w * scale);
|
||||||
|
h = (int)(h * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var resized = new System.Drawing.Bitmap(bitmap, w, h);
|
||||||
|
|
||||||
|
// Re-encode
|
||||||
|
var codec = System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders()
|
||||||
|
.First(c => c.MimeType == mimeType);
|
||||||
|
var encoderParams = new System.Drawing.Imaging.EncoderParameters(1);
|
||||||
|
encoderParams.Param[0] = new System.Drawing.Imaging.EncoderParameter(
|
||||||
|
System.Drawing.Imaging.Encoder.Quality, 75L);
|
||||||
|
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
resized.Save(output, codec, encoderParams);
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PageIterator pattern (Microsoft.Graph 5.x)
|
||||||
|
```csharp
|
||||||
|
// Source: Microsoft.Graph 5.x SDK pattern; PageIterator<TEntity, TCollectionPage>
|
||||||
|
var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
|
||||||
|
graphClient,
|
||||||
|
firstPage,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) return false;
|
||||||
|
results.Add(MapUser(user));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await pageIterator.IterateAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
### GraphDirectoryUser result record
|
||||||
|
```csharp
|
||||||
|
// New record for Phase 10 — placed in Services/ or Core/Models/
|
||||||
|
public record GraphDirectoryUser(
|
||||||
|
string DisplayName,
|
||||||
|
string UserPrincipalName,
|
||||||
|
string? Mail,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle);
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON shape of branding.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mspLogo": {
|
||||||
|
"base64": "iVBORw0KGgo...",
|
||||||
|
"mimeType": "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON shape of profiles.json (after Phase 10)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"name": "Contoso",
|
||||||
|
"tenantUrl": "https://contoso.sharepoint.com",
|
||||||
|
"clientId": "...",
|
||||||
|
"clientLogo": {
|
||||||
|
"base64": "/9j/4AAQ...",
|
||||||
|
"mimeType": "image/jpeg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fabrikam",
|
||||||
|
"tenantUrl": "https://fabrikam.sharepoint.com",
|
||||||
|
"clientId": "...",
|
||||||
|
"clientLogo": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Manual `@odata.nextLink` loop | `PageIterator<T, TPage>` | Microsoft.Graph 5.x (current) | Handles backoff, null-safety, async natively |
|
||||||
|
| `System.Drawing` everywhere | `System.Drawing` Windows-only | .NET 6 | No impact here — Windows-only project |
|
||||||
|
| Class-based Graph response models | Record/POCO `Value` collections | Microsoft.Graph 5.x | `response.Value` is `List<User>?` |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `Microsoft.Graph.Beta` namespace: not needed here — standard `/v1.0/users` endpoint sufficient
|
||||||
|
- `IAuthenticationProvider` (old Graph SDK): replaced by `BaseBearerTokenAuthenticationProvider` — already correct in `GraphClientFactory`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **CancellationToken in PageIterator.IterateAsync — exact overload in Graph SDK 5.74.0**
|
||||||
|
- What we know: `PageIterator` exists in `Microsoft.Graph.Core`; `IterateAsync` exists. Token passing confirmed in SDK samples.
|
||||||
|
- What's unclear: Whether `IterateAsync(CancellationToken)` overload exists in 5.74.0 or only the parameterless version.
|
||||||
|
- Recommendation: Check when implementing. If parameterless only, use `ct.IsCancellationRequested` inside callback to return `false` and stop iteration. Either approach works correctly.
|
||||||
|
|
||||||
|
2. **$filter=accountEnabled eq true and userType eq 'Member' — verified against real tenant?**
|
||||||
|
- STATE.md flags this as a pending todo: "Confirm `$filter=accountEnabled eq true and userType eq 'Member'` behavior without `ConsistencyLevel: eventual` against a real tenant before Phase 13 planning."
|
||||||
|
- Phase 10 implements the service; Phase 13 will exercise the filter in the ViewModel. The pending verification is appropriate for Phase 13.
|
||||||
|
- Recommendation: Implement the filter as specified. Flag in `GraphUserDirectoryService` with a comment noting the pending verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | xUnit 2.9.3 + Moq 4.20.72 |
|
||||||
|
| Config file | `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` |
|
||||||
|
| Quick run command | `dotnet test --filter "Category=Unit" --no-build` |
|
||||||
|
| Full suite command | `dotnet test` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| BRAND-01 | MSP logo saved to `branding.json` and reloaded correctly | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-01 | `BrandingRepository` round-trips `BrandingSettings` with `MspLogo` | unit | `dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-03 | `TenantProfile.ClientLogo` serializes/deserializes in `ProfileRepository` | unit | `dotnet test --filter "FullyQualifiedName~ProfileServiceTests" --no-build` | ✅ (extend existing) |
|
||||||
|
| BRAND-06 | PNG file accepted, returns `image/png` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | JPEG file accepted, returns `image/jpeg` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | BMP file rejected with descriptive error | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | File > 512 KB is auto-compressed (output ≤ 512 KB) | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | File ≤ 512 KB is not modified | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| (UDIR-02 infra) | `GetUsersAsync` follows all pages until exhausted | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| (UDIR-02 infra) | `GetUsersAsync` respects `CancellationToken` mid-iteration | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `dotnet test --filter "Category=Unit" --no-build`
|
||||||
|
- **Per wave merge:** `dotnet test`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/BrandingServiceTests.cs` — covers BRAND-06 + BRAND-01 import logic
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs` — covers BRAND-01 persistence
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs` — covers UDIR-02 infrastructure
|
||||||
|
|
||||||
|
*(Extend existing `ProfileServiceTests.cs` to verify `ClientLogo` round-trip — covers BRAND-03)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Codebase inspection — `SettingsRepository.cs`, `ProfileRepository.cs`, `GraphUserSearchService.cs`, `GraphClientFactory.cs`, `App.xaml.cs`, `TenantProfile.cs`, `AppSettings.cs`
|
||||||
|
- `SharepointToolbox.csproj` — confirms Microsoft.Graph 5.74.0, no System.Drawing explicit reference needed (net10.0-windows)
|
||||||
|
- `SharepointToolbox.Tests.csproj` — confirms xUnit 2.9.3, Moq 4.20.72 test stack
|
||||||
|
- `10-CONTEXT.md` — locked decisions, compression strategy, magic byte specs, model shapes
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- Microsoft.Graph 5.x SDK architecture — `PageIterator<T, TPage>` pattern confirmed in Graph SDK source and documentation; version 5.74.0 is current
|
||||||
|
- `System.Drawing.Common` Windows availability — confirmed by .NET documentation: available on Windows, restricted on non-Windows since .NET 6
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- `PageIterator.IterateAsync(CancellationToken)` overload availability in 5.74.0 specifically — needs compile-time verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — confirmed from csproj; zero new packages
|
||||||
|
- Architecture: HIGH — all patterns are direct clones of existing code in the repo
|
||||||
|
- Magic byte detection: HIGH — PNG/JPEG signatures are stable specs
|
||||||
|
- PageIterator pattern: MEDIUM — SDK version-specific overload needs verification at implementation time
|
||||||
|
- Pitfalls: HIGH — identified from codebase inspection and known .NET behaviors
|
||||||
|
|
||||||
|
**Research date:** 2026-04-08
|
||||||
|
**Valid until:** 2026-05-08 (stable domain — Microsoft.Graph minor versions change rarely)
|
||||||
Reference in New Issue
Block a user