444 lines
24 KiB
Markdown
444 lines
24 KiB
Markdown
# Architecture Patterns
|
|
|
|
**Domain:** C#/WPF MVVM desktop app — SharePoint Online MSP admin tool
|
|
**Feature scope:** Report branding (MSP/client logos in HTML) + User directory browse mode
|
|
**Researched:** 2026-04-08
|
|
**Confidence:** HIGH — based on direct codebase inspection, not assumptions
|
|
|
|
---
|
|
|
|
## Existing Architecture (Baseline)
|
|
|
|
```
|
|
Core/
|
|
Models/ — TenantProfile, AppSettings, domain records (all POCOs/records)
|
|
Messages/ — WeakReferenceMessenger value message types
|
|
Helpers/ — Static utility classes
|
|
|
|
Infrastructure/
|
|
Auth/ — MsalClientFactory, GraphClientFactory (MSAL PCA per-tenant + Graph SDK bridge)
|
|
Persistence/ — ProfileRepository, SettingsRepository, TemplateRepository (JSON, atomic write-then-replace)
|
|
Logging/ — LogPanelSink (Serilog sink to in-app RichTextBox)
|
|
|
|
Services/
|
|
Export/ — Concrete HTML/CSV export services per domain (no interface, consumed directly)
|
|
*.cs — Domain services with IXxx interfaces
|
|
|
|
ViewModels/
|
|
FeatureViewModelBase.cs — Abstract base: RunCommand, CancelCommand, ProgressValue, StatusMessage,
|
|
GlobalSites, WeakReferenceMessenger registration
|
|
MainWindowViewModel.cs — Toolbar: tenant picker, Connect, global site picker, broadcasts TenantSwitchedMessage
|
|
Tabs/ — One ViewModel per tab, all extend FeatureViewModelBase
|
|
ProfileManagementViewModel.cs — Profile CRUD dialog VM
|
|
|
|
Views/
|
|
Dialogs/ — ProfileManagementDialog, SitePickerDialog, ConfirmBulkOperationDialog, FolderBrowserDialog
|
|
Tabs/ — One UserControl per tab (XAML + code-behind)
|
|
|
|
App.xaml.cs — Generic Host IServiceCollection DI registration for all layers
|
|
```
|
|
|
|
### Key Patterns Already Established
|
|
|
|
| Pattern | How It Works |
|
|
|---------|-------------|
|
|
| Tenant switching | `MainWindowViewModel.OnSelectedProfileChanged` broadcasts `TenantSwitchedMessage` via `WeakReferenceMessenger`; each tab VM overrides `OnTenantSwitched(profile)` |
|
|
| Global site propagation | `GlobalSitesChangedMessage` received in `FeatureViewModelBase.OnGlobalSitesReceived` |
|
|
| HTML export | Concrete service class (e.g. `UserAccessHtmlExportService`), `BuildHtml(entries)` returns a string, `WriteAsync(entries, path, ct)` writes it. No interface. Pure data-in, HTML-out. |
|
|
| JSON persistence | Repository pattern: constructor takes `string filePath`, atomic write via `.tmp` + round-trip JSON validation before `File.Move`, `SemaphoreSlim` write lock. |
|
|
| DI registration | All in `App.xaml.cs RegisterServices()`. Export services and ViewModels are `AddTransient`; shared infrastructure is `AddSingleton`. |
|
|
| Dialog factory | View code-behind sets `ViewModel.OpenXxxDialog = () => new XxxDialog(...)` — keeps dialogs out of ViewModel layer |
|
|
| People-picker search | `IGraphUserSearchService.SearchUsersAsync(clientId, query, maxResults, ct)` calls Graph `/users?$filter=startsWith(...)` with `ConsistencyLevel: eventual` |
|
|
| Test constructor | `UserAccessAuditViewModel` has a `internal` 3-param constructor without export services — test pattern to replicate for new injections |
|
|
|
|
---
|
|
|
|
## Feature 1: Report Branding (MSP/Client Logos in HTML Reports)
|
|
|
|
### What It Needs
|
|
|
|
- **MSP logo** — one global image, shown in every HTML report from every tenant
|
|
- **Client logo** — one image per tenant, shown in reports for that tenant only
|
|
- **Storage** — base64-encoded strings in JSON (no separate image files — preserves atomic save semantics and single-data-folder design)
|
|
- **Embedding** — `data:image/...;base64,...` `<img>` tag injected into the HTML header (maintains self-contained HTML invariant — zero external file references)
|
|
- **User action** — file picker → read bytes → detect MIME type → convert to base64 → store in JSON → preview in UI
|
|
|
|
### New Components (create from scratch)
|
|
|
|
**`Core/Models/BrandingSettings.cs`**
|
|
```csharp
|
|
public class BrandingSettings
|
|
{
|
|
public string? MspLogoBase64 { get; set; }
|
|
public string? MspLogoMimeType { get; set; } // "image/png", "image/jpeg", etc.
|
|
}
|
|
```
|
|
Belongs in Core/Models alongside AppSettings. Kept separate — branding may grow independently of general app settings.
|
|
|
|
**`Core/Models/ReportBranding.cs`**
|
|
```csharp
|
|
public record ReportBranding(
|
|
string? MspLogoBase64,
|
|
string? MspLogoMimeType,
|
|
string? ClientLogoBase64,
|
|
string? ClientLogoMimeType);
|
|
```
|
|
Lightweight data transfer record assembled at export time from BrandingSettings + current TenantProfile. Not persisted directly — constructed on demand.
|
|
|
|
**`Infrastructure/Persistence/BrandingRepository.cs`**
|
|
Same pattern as `SettingsRepository`: constructor takes `string filePath`, atomic write-then-replace, `SemaphoreSlim` write lock. File: `%AppData%\SharepointToolbox\branding.json`.
|
|
|
|
**`Services/BrandingService.cs`**
|
|
```csharp
|
|
public class BrandingService
|
|
{
|
|
public Task<BrandingSettings> GetBrandingAsync();
|
|
public Task SetMspLogoAsync(string filePath); // reads file, detects MIME, converts to base64, saves
|
|
public Task ClearMspLogoAsync();
|
|
}
|
|
```
|
|
Thin orchestration, same pattern as `SettingsService`. MSP logo only — client logo is managed via `ProfileService` (it belongs to `TenantProfile`).
|
|
|
|
### Modified Components
|
|
|
|
**`Core/Models/TenantProfile.cs`** — Add two nullable string properties:
|
|
```csharp
|
|
public string? ClientLogoBase64 { get; set; }
|
|
public string? ClientLogoMimeType { get; set; }
|
|
```
|
|
This is backward-compatible. `ProfileRepository` uses `JsonSerializer` with `PropertyNameCaseInsensitive: true` — missing JSON fields deserialize to null without error. Existing `profiles.json` files continue to load correctly.
|
|
|
|
**All HTML export services** — Add `ReportBranding? branding = null` optional parameter to every `BuildHtml()` overload. When non-null and at least one logo is present, inject a branding header div between `<body>` open and `<h1>`:
|
|
|
|
```html
|
|
<div class="brand-header" style="display:flex;align-items:center;gap:16px;padding:16px 24px 0;">
|
|
<!-- only rendered if logo present -->
|
|
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="MSP" />
|
|
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="Client" />
|
|
</div>
|
|
```
|
|
|
|
When `branding` is null (existing callers) the block is omitted entirely. No behavior change for callers that do not pass branding.
|
|
|
|
Affected services (all in `Services/Export/`):
|
|
- `HtmlExportService` (two `BuildHtml` overloads — `PermissionEntry` and `SimplifiedPermissionEntry`)
|
|
- `UserAccessHtmlExportService`
|
|
- `StorageHtmlExportService` (two `BuildHtml` overloads — with and without `FileTypeMetric`)
|
|
- `SearchHtmlExportService`
|
|
- `DuplicatesHtmlExportService`
|
|
|
|
**ViewModels that call HTML export** — All `ExportHtmlAsync` methods need to resolve branding before calling the export service. The ViewModel calls `BrandingService.GetBrandingAsync()` and reads `_currentProfile.ClientLogoBase64` to assemble a `ReportBranding`, then passes it to `BuildHtml`.
|
|
|
|
Affected ViewModels: `PermissionsViewModel`, `UserAccessAuditViewModel`, `StorageViewModel`, `SearchViewModel`, `DuplicatesViewModel`. Each gets `BrandingService` injected via constructor.
|
|
|
|
**`ViewModels/Tabs/SettingsViewModel.cs`** — Add MSP logo management:
|
|
```csharp
|
|
[ObservableProperty] private string? _mspLogoPreviewBase64;
|
|
public RelayCommand BrowseMspLogoCommand { get; }
|
|
public RelayCommand ClearMspLogoCommand { get; }
|
|
```
|
|
On browse: open `OpenFileDialog` (filter: PNG, JPG, GIF) → call `BrandingService.SetMspLogoAsync(path)` → reload and refresh `MspLogoPreviewBase64`.
|
|
|
|
**`Views/Tabs/SettingsView.xaml`** — Add a "Report Branding — MSP Logo" section:
|
|
- `<Image>` bound to `MspLogoPreviewBase64` via a base64-to-BitmapSource converter
|
|
- "Browse Logo" button → `BrowseMspLogoCommand`
|
|
- "Clear" button → `ClearMspLogoCommand`
|
|
- Note label: "Applies to all reports"
|
|
|
|
**Client logo placement:** Client logo belongs to a `TenantProfile`, not to global settings. The natural place to manage it is `ProfileManagementDialog` (already handles profile CRUD). Add logo fields there rather than in SettingsView.
|
|
|
|
**`ViewModels/ProfileManagementViewModel.cs`** — Add client logo management per profile:
|
|
```csharp
|
|
[ObservableProperty] private string? _clientLogoPreviewBase64;
|
|
public RelayCommand BrowseClientLogoCommand { get; }
|
|
public RelayCommand ClearClientLogoCommand { get; }
|
|
```
|
|
On browse: read image bytes → base64 → set on the being-edited `TenantProfile` object before saving. Uses `ProfileService.AddProfileAsync` / rename pipeline that already exists.
|
|
|
|
**`Views/Dialogs/ProfileManagementDialog.xaml`** — Add client logo fields to the add/edit profile form (same pattern as SettingsView branding section).
|
|
|
|
### Data Flow: Report Branding
|
|
|
|
```
|
|
User picks MSP logo (SettingsView "Browse Logo" button)
|
|
→ SettingsViewModel.BrowseMspLogoCommand
|
|
→ OpenFileDialog in View code-behind or VM (follow existing BrowseFolder pattern)
|
|
→ BrandingService.SetMspLogoAsync(path)
|
|
→ File.ReadAllBytesAsync → Convert.ToBase64String
|
|
→ detect MIME from extension (.png → image/png, .jpg/.jpeg → image/jpeg, .gif → image/gif)
|
|
→ BrandingRepository.SaveAsync(BrandingSettings)
|
|
→ ViewModel refreshes MspLogoPreviewBase64
|
|
|
|
User runs export (e.g. ExportHtmlCommand in UserAccessAuditViewModel)
|
|
→ BrandingService.GetBrandingAsync() → BrandingSettings
|
|
→ reads _currentProfile.ClientLogoBase64, _currentProfile.ClientLogoMimeType
|
|
→ new ReportBranding(mspBase64, mspMime, clientBase64, clientMime)
|
|
→ UserAccessHtmlExportService.BuildHtml(entries, branding)
|
|
→ injects <img> data URIs in header when base64 is non-null
|
|
→ writes HTML file
|
|
```
|
|
|
|
---
|
|
|
|
## Feature 2: User Directory Browse Mode
|
|
|
|
### What It Needs
|
|
|
|
The existing `UserAccessAuditView` has a people-picker: search box → Graph API `startsWith` filter → autocomplete dropdown → add to `SelectedUsers`. Directory browse mode is an alternative to the search box: show a paginated, filterable list of all tenant users, allow multi-select, bulk-add to `SelectedUsers`.
|
|
|
|
This is purely additive. The underlying audit logic (`IUserAccessAuditService`, `RunOperationAsync`, `SelectedUsers` collection, export commands) is completely unchanged.
|
|
|
|
### New Components (create from scratch)
|
|
|
|
**`Core/Models/PagedUserResult.cs`**
|
|
```csharp
|
|
public record PagedUserResult(
|
|
IReadOnlyList<GraphUserResult> Users,
|
|
string? NextPageToken); // null = last page
|
|
```
|
|
|
|
**`Services/IGraphUserDirectoryService.cs`**
|
|
```csharp
|
|
public interface IGraphUserDirectoryService
|
|
{
|
|
Task<PagedUserResult> GetUsersPageAsync(
|
|
string clientId,
|
|
string? filter = null,
|
|
string? pageToken = null,
|
|
int pageSize = 100,
|
|
CancellationToken ct = default);
|
|
}
|
|
```
|
|
|
|
**`Services/GraphUserDirectoryService.cs`**
|
|
Reuses `GraphClientFactory` (already injected elsewhere). Calls `graphClient.Users.GetAsync()` without the `startsWith` constraint used in search — uses `$top=100` with cursor-based paging via Graph's `@odata.nextLink`. Returns `PagedUserResult` so callers control pagination. Uses `ConsistencyLevel: eventual` + `$count=true` (same as existing search service).
|
|
|
|
### Modified Components
|
|
|
|
**`ViewModels/Tabs/UserAccessAuditViewModel.cs`** — Add browse mode state:
|
|
|
|
```csharp
|
|
[ObservableProperty] private bool _isBrowseModeActive;
|
|
[ObservableProperty] private ObservableCollection<GraphUserResult> _directoryUsers = new();
|
|
[ObservableProperty] private string _directoryFilter = string.Empty;
|
|
[ObservableProperty] private bool _isLoadingDirectory;
|
|
[ObservableProperty] private bool _hasMoreDirectoryPages;
|
|
|
|
private string? _directoryNextPageToken;
|
|
|
|
public IAsyncRelayCommand LoadDirectoryCommand { get; }
|
|
public IAsyncRelayCommand LoadMoreDirectoryCommand { get; }
|
|
public RelayCommand<IList<GraphUserResult>> AddDirectoryUsersCommand { get; }
|
|
```
|
|
|
|
`partial void OnIsBrowseModeActiveChanged(bool value)` → when `value == true`, fire `LoadDirectoryCommand` to populate page 1.
|
|
|
|
`partial void OnDirectoryFilterChanged(string value)` → debounce 300ms (same pattern as `OnSearchQueryChanged`), re-fire `LoadDirectoryCommand` with new filter, clear `_directoryNextPageToken`.
|
|
|
|
The `IGraphUserDirectoryService` is added to the constructor. The internal test constructor (currently 3 params) gets a 4-param overload adding the directory service with a null-safe default, or a new explicit test constructor.
|
|
|
|
**`App.xaml.cs RegisterServices()`** — Add:
|
|
```csharp
|
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
|
```
|
|
The `UserAccessAuditViewModel` transient registration picks up the new injection automatically (DI resolves by type).
|
|
|
|
**`UserAccessAuditViewModel.OnTenantSwitched`** — Also clear `DirectoryUsers`, reset `_directoryNextPageToken`, `HasMoreDirectoryPages`, `IsLoadingDirectory`.
|
|
|
|
**`Views/Tabs/UserAccessAuditView.xaml`** — Add to the top of the left panel:
|
|
- Mode toggle: two `RadioButton`s or `ToggleButton`s bound to `IsBrowseModeActive`
|
|
- "Search" panel: existing `GroupBox` shown when `IsBrowseModeActive == false`
|
|
- "Browse" panel: new `GroupBox` shown when `IsBrowseModeActive == true`, containing:
|
|
- Filter `TextBox` bound to `DirectoryFilter`
|
|
- `ListView` with `SelectionMode="Extended"` bound to `DirectoryUsers`, `SelectionChanged` handler in code-behind
|
|
- "Add Selected" `Button` → `AddDirectoryUsersCommand`
|
|
- "Load more" `Button` shown when `HasMoreDirectoryPages == true` → `LoadMoreDirectoryCommand`
|
|
- Loading indicator (existing `IsSearching` pattern, but for `IsLoadingDirectory`)
|
|
- Show/hide panels via `DataTrigger` on `IsBrowseModeActive`
|
|
|
|
**`Views/Tabs/UserAccessAuditView.xaml.cs`** — Add `SelectionChanged` handler to pass `ListView.SelectedItems` (as `IList<GraphUserResult>`) to `AddDirectoryUsersCommand`. Follow the existing `SearchResultsListBox_SelectionChanged` pattern.
|
|
|
|
### Data Flow: Directory Browse Mode
|
|
|
|
```
|
|
User clicks "Browse" mode toggle
|
|
→ IsBrowseModeActive = true
|
|
→ OnIsBrowseModeActiveChanged fires LoadDirectoryCommand
|
|
→ GraphUserDirectoryService.GetUsersPageAsync(clientId, filter: null, pageToken: null, 100, ct)
|
|
→ Graph GET /users?$select=displayName,userPrincipalName,mail&$top=100&$orderby=displayName
|
|
→ returns PagedUserResult { Users = [...100 items], NextPageToken = "..." }
|
|
→ DirectoryUsers = new collection of returned users
|
|
→ HasMoreDirectoryPages = (NextPageToken != null)
|
|
→ _directoryNextPageToken = returned token
|
|
|
|
User types in DirectoryFilter
|
|
→ debounce 300ms
|
|
→ LoadDirectoryCommand re-fires with filter
|
|
→ DirectoryUsers replaced with filtered page 1
|
|
|
|
User selects users in ListView + clicks "Add Selected"
|
|
→ AddDirectoryUsersCommand(selectedItems)
|
|
→ for each: if not already in SelectedUsers by UPN → SelectedUsers.Add(user)
|
|
|
|
User clicks "Load more"
|
|
→ LoadMoreDirectoryCommand
|
|
→ GetUsersPageAsync(clientId, currentFilter, _directoryNextPageToken, 100, ct)
|
|
→ DirectoryUsers items appended (not replaced)
|
|
→ _directoryNextPageToken updated
|
|
```
|
|
|
|
---
|
|
|
|
## Component Boundary Summary
|
|
|
|
### New Components (create)
|
|
|
|
| Component | Layer | Type | Purpose |
|
|
|-----------|-------|------|---------|
|
|
| `BrandingSettings` | Core/Models | class | MSP logo storage (base64 + MIME type) |
|
|
| `ReportBranding` | Core/Models | record | Data passed to `BuildHtml` overloads at export time |
|
|
| `BrandingRepository` | Infrastructure/Persistence | class | JSON load/save for `BrandingSettings` |
|
|
| `BrandingService` | Services | class | Orchestrates logo file read / MIME detect / base64 convert / save |
|
|
| `PagedUserResult` | Core/Models | record | Page of `GraphUserResult` items + next-page token |
|
|
| `IGraphUserDirectoryService` | Services | interface | Contract for paginated tenant user enumeration |
|
|
| `GraphUserDirectoryService` | Services | class | Graph API user listing with cursor pagination |
|
|
|
|
Total new files: 7
|
|
|
|
### Modified Components (extend)
|
|
|
|
| Component | Change | Risk |
|
|
|-----------|--------|------|
|
|
| `TenantProfile` | + 2 nullable logo props | LOW — JSON backward-compatible |
|
|
| `HtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW — optional param, existing callers unaffected |
|
|
| `UserAccessHtmlExportService` | + optional `ReportBranding?` | LOW |
|
|
| `StorageHtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW |
|
|
| `SearchHtmlExportService` | + optional `ReportBranding?` | LOW |
|
|
| `DuplicatesHtmlExportService` | + optional `ReportBranding?` | LOW |
|
|
| `PermissionsViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
| `UserAccessAuditViewModel` | + inject `BrandingService` + `IGraphUserDirectoryService`, browse mode state/commands | MEDIUM |
|
|
| `StorageViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
| `SearchViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
| `DuplicatesViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
| `SettingsViewModel` | + inject `BrandingService`, MSP logo commands + preview property | LOW |
|
|
| `ProfileManagementViewModel` | + client logo browse/preview/clear | LOW |
|
|
| `SettingsView.xaml` | + branding section with logo preview + buttons | LOW |
|
|
| `ProfileManagementDialog.xaml` | + client logo fields | LOW |
|
|
| `UserAccessAuditView.xaml` | + mode toggle + browse panel in left column | MEDIUM |
|
|
| `App.xaml.cs RegisterServices()` | + 3 new registrations | LOW |
|
|
|
|
Total modified files: 17
|
|
|
|
---
|
|
|
|
## Build Order (Dependency-Aware)
|
|
|
|
The two features are independent of each other. Phases can run in parallel if worked by two developers; solo they should follow top-to-bottom order.
|
|
|
|
### Phase A — Data Models (no dependencies)
|
|
1. `Core/Models/BrandingSettings.cs` (new)
|
|
2. `Core/Models/ReportBranding.cs` (new)
|
|
3. `Core/Models/PagedUserResult.cs` (new)
|
|
4. `Core/Models/TenantProfile.cs` — add nullable logo props (modification)
|
|
|
|
All files are POCOs/records. Unit-testable in isolation. No risk.
|
|
|
|
### Phase B — Persistence + Service Layer
|
|
5. `Infrastructure/Persistence/BrandingRepository.cs` (new) — depends on BrandingSettings
|
|
6. `Services/BrandingService.cs` (new) — depends on BrandingRepository
|
|
7. `Services/IGraphUserDirectoryService.cs` (new) — depends on PagedUserResult
|
|
8. `Services/GraphUserDirectoryService.cs` (new) — depends on GraphClientFactory (already exists)
|
|
|
|
Unit tests for BrandingService (mock repository) and GraphUserDirectoryService (mock Graph client) can be written at this phase.
|
|
|
|
### Phase C — HTML Export Service Extensions
|
|
9. All 5 `Services/Export/*HtmlExportService.cs` modifications — add optional `ReportBranding?` param
|
|
|
|
These are independent of each other. Tests: verify that passing `null` branding produces identical HTML to current output (regression), and that passing a branding record injects the expected `<img>` tags.
|
|
|
|
### Phase D — ViewModel Integration (branding)
|
|
10. `SettingsViewModel.cs` — add MSP logo commands + preview
|
|
11. `ProfileManagementViewModel.cs` — add client logo commands + preview
|
|
12. `PermissionsViewModel.cs` — add BrandingService injection, use in ExportHtmlAsync
|
|
13. `StorageViewModel.cs` — same
|
|
14. `SearchViewModel.cs` — same
|
|
15. `DuplicatesViewModel.cs` — same
|
|
16. `App.xaml.cs` — register BrandingRepository, BrandingService
|
|
|
|
Steps 12-15 follow an identical pattern and can be batched together.
|
|
|
|
### Phase E — ViewModel Integration (directory browse)
|
|
17. `UserAccessAuditViewModel.cs` — add IGraphUserDirectoryService injection, browse mode state/commands
|
|
|
|
Note: UserAccessAuditViewModel also gets BrandingService at this phase (from Phase D pattern). Do both together to avoid touching the constructor twice.
|
|
|
|
### Phase F — View Layer (branding UI)
|
|
18. `SettingsView.xaml` — add MSP branding section
|
|
19. `ProfileManagementDialog.xaml` — add client logo fields
|
|
|
|
Requires a base64-to-BitmapSource `IValueConverter` (add to `Views/Converters/`). This is a common WPF pattern — implement once, reuse in both views.
|
|
|
|
### Phase G — View Layer (directory browse UI)
|
|
20. `UserAccessAuditView.xaml` — add mode toggle + browse panel
|
|
21. `UserAccessAuditView.xaml.cs` — add SelectionChanged handler for directory ListView
|
|
|
|
This is the highest-risk UI change: the left panel is being restructured. Do this last, after all ViewModel behavior is proven by unit tests.
|
|
|
|
---
|
|
|
|
## Anti-Patterns to Avoid
|
|
|
|
### Storing Logo Images as Separate Files
|
|
**Why bad:** Breaks the single-data-folder design. Reports become non-self-contained if they reference external paths. Atomic save semantics break.
|
|
**Instead:** Base64-encode into JSON. Logo thumbnails are typically 10-200KB. Base64 overhead (~33%) is negligible.
|
|
|
|
### Adding an `IHtmlExportService` Interface Just for Branding
|
|
**Why bad:** The existing pattern is 5 concrete classes with no interfaces, consumed directly by ViewModels. Adding an interface for a parameter change creates ceremony without value.
|
|
**Instead:** Add `ReportBranding? branding = null` as optional parameter. Existing callers compile unchanged.
|
|
|
|
### Loading All Tenant Users at Once
|
|
**Why bad:** Enterprise tenants regularly have 20,000-100,000 users. A full load blocks the UI for 30+ seconds and allocates hundreds of MB.
|
|
**Instead:** `PagedUserResult` pattern — page 1 on mode toggle, "Load more" button, server-side filter applied to DirectoryFilter text.
|
|
|
|
### Async in ViewModel Constructor
|
|
**Why bad:** DI constructs ViewModels synchronously on the UI thread. Async work in constructors requires fire-and-forget which loses exceptions.
|
|
**Instead:** `partial void OnIsBrowseModeActiveChanged` fires `LoadDirectoryCommand` when browse mode activates. Constructor only wires up commands and state.
|
|
|
|
### Client Logo in `AppSettings` or `BrandingSettings`
|
|
**Why bad:** Client logos are per-tenant. `AppSettings` and `BrandingSettings` are global. Mixing them makes per-profile deletion awkward and serialization structure unclear.
|
|
**Instead:** `ClientLogoBase64` + `ClientLogoMimeType` directly on `TenantProfile` (serialized in `profiles.json`). MSP logo goes in `branding.json` via `BrandingRepository`.
|
|
|
|
### Changing `BuildHtml` Signatures to Required Parameters
|
|
**Why bad:** All 5 HTML export services currently have callers without branding. Making the parameter required is a breaking change forcing simultaneous updates across 5 VMs.
|
|
**Instead:** `ReportBranding? branding = null` is optional. Inject only where branding is desired. Existing call sites remain unchanged.
|
|
|
|
---
|
|
|
|
## Scalability Considerations
|
|
|
|
| Concern | Impact | Mitigation |
|
|
|---------|--------|------------|
|
|
| Logo storage size in JSON | PNG logos base64-encoded: 10-200KB per logo. `profiles.json` grows by at most that per tenant | Acceptable — config files, not bulk data |
|
|
| HTML report file size | +2-10KB per logo (base64 inline) | Negligible — reports are already 100-500KB |
|
|
| Directory browse load time | 100-user pages from Graph: ~200-500ms per page | Loading indicator, pagination. Acceptable UX. |
|
|
| Large tenants (50k+ users) | Full load would take minutes and exceed memory budgets | Pagination via `PagedUserResult` prevents this entirely |
|
|
| ViewModel constructor overhead | BrandingService adds one lazy JSON read at first export | Not at construction — no startup impact |
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
All findings are based on direct inspection of the codebase at `C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/`. No external research needed — this is an integration architecture document for a known codebase.
|
|
|
|
Key files examined:
|
|
- `Core/Models/TenantProfile.cs`, `AppSettings.cs`
|
|
- `Infrastructure/Persistence/ProfileRepository.cs`, `SettingsRepository.cs`
|
|
- `Infrastructure/Auth/GraphClientFactory.cs`
|
|
- `Services/SettingsService.cs`, `ProfileService.cs`
|
|
- `Services/GraphUserSearchService.cs`, `IGraphUserSearchService.cs`
|
|
- `Services/Export/HtmlExportService.cs`, `UserAccessHtmlExportService.cs`, `StorageHtmlExportService.cs`
|
|
- `ViewModels/FeatureViewModelBase.cs`, `MainWindowViewModel.cs`
|
|
- `ViewModels/Tabs/UserAccessAuditViewModel.cs`, `SettingsViewModel.cs`
|
|
- `Views/Tabs/UserAccessAuditView.xaml`, `SettingsView.xaml`
|
|
- `App.xaml.cs`
|