Files
Sharepoint-Toolbox/.planning/research/ARCHITECTURE.md
2026-04-08 10:57:27 +02:00

24 KiB

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)
  • Embeddingdata: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

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

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

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:

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

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

[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:

[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

public record PagedUserResult(
    IReadOnlyList<GraphUserResult> Users,
    string? NextPageToken);  // null = last page

Services/IGraphUserDirectoryService.cs

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:

[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:

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 RadioButtons or ToggleButtons 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" ButtonAddDirectoryUsersCommand
    • "Load more" Button shown when HasMoreDirectoryPages == trueLoadMoreDirectoryCommand
    • 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

  1. Infrastructure/Persistence/BrandingRepository.cs (new) — depends on BrandingSettings
  2. Services/BrandingService.cs (new) — depends on BrandingRepository
  3. Services/IGraphUserDirectoryService.cs (new) — depends on PagedUserResult
  4. 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

  1. 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)

  1. SettingsViewModel.cs — add MSP logo commands + preview
  2. ProfileManagementViewModel.cs — add client logo commands + preview
  3. PermissionsViewModel.cs — add BrandingService injection, use in ExportHtmlAsync
  4. StorageViewModel.cs — same
  5. SearchViewModel.cs — same
  6. DuplicatesViewModel.cs — same
  7. App.xaml.cs — register BrandingRepository, BrandingService

Steps 12-15 follow an identical pattern and can be batched together.

Phase E — ViewModel Integration (directory browse)

  1. 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)

  1. SettingsView.xaml — add MSP branding section
  2. 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)

  1. UserAccessAuditView.xaml — add mode toggle + browse panel
  2. 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