diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b633085..ceab3fb 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -60,7 +60,10 @@ 2. A `PermissionConsolidator` service accepts a flat list of permission rows and returns a consolidated list where duplicate user+level rows are merged 3. Consolidation logic has unit test coverage — a known 10-row input with 3 duplicate pairs produces the expected 7-row output 4. Existing HTML export services compile and produce identical output when consolidation is not applied (opt-in, defaults off) -**Plans**: TBD +**Plans:** 2 plans +Plans: +- [ ] 15-01-PLAN.md — Models (LocationInfo, ConsolidatedPermissionEntry) + PermissionConsolidator service +- [ ] 15-02-PLAN.md — Unit tests (10 test cases) + full solution build verification ### Phase 16: Report Consolidation Toggle **Goal**: Users can choose to merge duplicate permission entries per export through a toggle in the export settings dialog @@ -114,7 +117,7 @@ | 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 | | 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 | | 10-14 | v2.2 | 14/14 | Shipped | 2026-04-09 | -| 15. Consolidation Data Model | v2.3 | 0/? | Not started | — | +| 15. Consolidation Data Model | v2.3 | 0/2 | Planning | — | | 16. Report Consolidation Toggle | v2.3 | 0/? | Not started | — | | 17. Group Expansion in HTML Reports | v2.3 | 0/? | Not started | — | | 18. Auto-Take Ownership | v2.3 | 0/? | Not started | — | diff --git a/.planning/phases/15-consolidation-data-model/15-01-PLAN.md b/.planning/phases/15-consolidation-data-model/15-01-PLAN.md new file mode 100644 index 0000000..580fc1d --- /dev/null +++ b/.planning/phases/15-consolidation-data-model/15-01-PLAN.md @@ -0,0 +1,239 @@ +--- +phase: 15-consolidation-data-model +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - SharepointToolbox/Core/Models/LocationInfo.cs + - SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs + - SharepointToolbox/Core/Helpers/PermissionConsolidator.cs +autonomous: true +requirements: + - RPT-04 +must_haves: + truths: + - "LocationInfo record holds five location fields from UserAccessEntry" + - "ConsolidatedPermissionEntry holds key fields plus a list of LocationInfo with LocationCount" + - "PermissionConsolidator.Consolidate merges entries with identical key into single rows" + - "MakeKey uses pipe-delimited case-insensitive composite of UserLogin+PermissionLevel+AccessType+GrantedThrough" + - "Empty input returns empty list" + artifacts: + - path: "SharepointToolbox/Core/Models/LocationInfo.cs" + provides: "Location data record" + contains: "public record LocationInfo" + - path: "SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs" + provides: "Consolidated permission model" + contains: "public record ConsolidatedPermissionEntry" + - path: "SharepointToolbox/Core/Helpers/PermissionConsolidator.cs" + provides: "Consolidation logic" + exports: ["Consolidate", "MakeKey"] + key_links: + - from: "SharepointToolbox/Core/Helpers/PermissionConsolidator.cs" + to: "SharepointToolbox/Core/Models/UserAccessEntry.cs" + via: "accepts IReadOnlyList" + pattern: "IReadOnlyList" + - from: "SharepointToolbox/Core/Helpers/PermissionConsolidator.cs" + to: "SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs" + via: "returns IReadOnlyList" + pattern: "IReadOnlyList" + - from: "SharepointToolbox/Core/Helpers/PermissionConsolidator.cs" + to: "SharepointToolbox/Core/Models/LocationInfo.cs" + via: "constructs LocationInfo from UserAccessEntry fields" + pattern: "new LocationInfo" +--- + + +Create the consolidation data model and merge service for permission report consolidation. + +Purpose: Establish the data shape (LocationInfo, ConsolidatedPermissionEntry) and pure-function merge logic (PermissionConsolidator) so that Phase 16 can wire them into the export pipeline. Zero API calls, zero UI — just models and a static helper. + +Output: Three production files — two model records and one static consolidation service. + + + +@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/dev/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/15-consolidation-data-model/15-CONTEXT.md +@.planning/phases/15-consolidation-data-model/15-RESEARCH.md + + + +```csharp +namespace SharepointToolbox.Core.Models; + +public enum AccessType { Direct, Group, Inherited } + +public record UserAccessEntry( + string UserDisplayName, + string UserLogin, + string SiteUrl, + string SiteTitle, + string ObjectType, + string ObjectTitle, + string ObjectUrl, + string PermissionLevel, + AccessType AccessType, + string GrantedThrough, + bool IsHighPrivilege, + bool IsExternalUser +); +``` + + + + + + + Task 1: Create LocationInfo and ConsolidatedPermissionEntry model records + SharepointToolbox/Core/Models/LocationInfo.cs, SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs + +Create two new C# positional record files in `Core/Models/`. + +**LocationInfo.cs** — namespace `SharepointToolbox.Core.Models`: +```csharp +public record LocationInfo( + string SiteUrl, + string SiteTitle, + string ObjectTitle, + string ObjectUrl, + string ObjectType +); +``` +Lightweight record holding the five location-related fields extracted from UserAccessEntry when rows are merged. + +**ConsolidatedPermissionEntry.cs** — namespace `SharepointToolbox.Core.Models`: +```csharp +public record ConsolidatedPermissionEntry( + string UserDisplayName, + string UserLogin, + string PermissionLevel, + AccessType AccessType, + string GrantedThrough, + bool IsHighPrivilege, + bool IsExternalUser, + IReadOnlyList Locations +) +{ + public int LocationCount => Locations.Count; +} +``` +- Holds the four key fields (UserLogin, PermissionLevel, AccessType, GrantedThrough) plus carried-forward fields (UserDisplayName, IsHighPrivilege, IsExternalUser). +- `Locations` is an `IReadOnlyList` containing all merged locations. +- `LocationCount` is a computed convenience property. +- Do NOT add any methods, constructors, or logic beyond the record definition and LocationCount property. + + + cd C:/Users/dev/Documents/projets/Sharepoint && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -v q 2>&1 | tail -5 + + Both record files exist, compile without errors, and are in the SharepointToolbox.Core.Models namespace. ConsolidatedPermissionEntry.LocationCount returns Locations.Count. + + + + Task 2: Create PermissionConsolidator static helper + SharepointToolbox/Core/Helpers/PermissionConsolidator.cs + +Create `PermissionConsolidator.cs` in `Core/Helpers/` — namespace `SharepointToolbox.Core.Helpers`. + +Follow the existing `DuplicatesService.MakeKey()` pattern for composite key generation. + +```csharp +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Core.Helpers; + +/// +/// Merges a flat list of UserAccessEntry rows into consolidated entries +/// where rows with identical (UserLogin, PermissionLevel, AccessType, GrantedThrough) +/// are grouped into a single row with multiple locations. +/// +public static class PermissionConsolidator +{ + /// + /// Builds a pipe-delimited, case-insensitive composite key from the four key fields. + /// + internal static string MakeKey(UserAccessEntry entry) + { + return string.Join("|", + entry.UserLogin.ToLowerInvariant(), + entry.PermissionLevel.ToLowerInvariant(), + entry.AccessType.ToString(), + entry.GrantedThrough.ToLowerInvariant()); + } + + /// + /// Groups entries by composite key and returns consolidated rows. + /// Each group's first entry provides UserDisplayName, IsHighPrivilege, IsExternalUser. + /// All entries in a group contribute a LocationInfo to the Locations list. + /// Results are ordered by UserLogin then PermissionLevel. + /// + public static IReadOnlyList Consolidate( + IReadOnlyList entries) + { + if (entries.Count == 0) + return Array.Empty(); + + return entries + .GroupBy(e => MakeKey(e)) + .Select(g => + { + var first = g.First(); + var locations = g.Select(e => new LocationInfo( + e.SiteUrl, e.SiteTitle, e.ObjectTitle, e.ObjectUrl, e.ObjectType + )).ToList(); + + return new ConsolidatedPermissionEntry( + first.UserDisplayName, + first.UserLogin, + first.PermissionLevel, + first.AccessType, + first.GrantedThrough, + first.IsHighPrivilege, + first.IsExternalUser, + locations); + }) + .OrderBy(c => c.UserLogin) + .ThenBy(c => c.PermissionLevel) + .ToList(); + } +} +``` + +Key implementation details: +- `MakeKey` is `internal` so tests can access it via `[InternalsVisibleTo]` or by testing through `Consolidate`. +- Use `.ToLowerInvariant()` on UserLogin, PermissionLevel, GrantedThrough (string key fields). AccessType is an enum — use `.ToString()` (case-stable). +- Empty input short-circuits to `Array.Empty<>()`. +- LINQ GroupBy + Select pattern — no mutable dictionaries. +- OrderBy UserLogin then PermissionLevel for deterministic output. + + + cd C:/Users/dev/Documents/projets/Sharepoint && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -v q 2>&1 | tail -5 + + PermissionConsolidator.cs compiles. Consolidate method accepts IReadOnlyList of UserAccessEntry, returns IReadOnlyList of ConsolidatedPermissionEntry. MakeKey produces pipe-delimited lowercase composite key. + + + + + +Full solution build passes: +```bash +cd C:/Users/dev/Documents/projets/Sharepoint && dotnet build --no-restore -v q +``` + + + +- LocationInfo.cs and ConsolidatedPermissionEntry.cs exist in Core/Models/ with correct record signatures +- PermissionConsolidator.cs exists in Core/Helpers/ with Consolidate and MakeKey methods +- All three files compile as part of the SharepointToolbox project +- No changes to any existing files + + + +After completion, create `.planning/phases/15-consolidation-data-model/15-01-SUMMARY.md` + diff --git a/.planning/phases/15-consolidation-data-model/15-02-PLAN.md b/.planning/phases/15-consolidation-data-model/15-02-PLAN.md new file mode 100644 index 0000000..e8badaf --- /dev/null +++ b/.planning/phases/15-consolidation-data-model/15-02-PLAN.md @@ -0,0 +1,250 @@ +--- +phase: 15-consolidation-data-model +plan: 02 +type: execute +wave: 2 +depends_on: + - 15-01 +files_modified: + - SharepointToolbox.Tests/Helpers/PermissionConsolidatorTests.cs +autonomous: true +requirements: + - RPT-04 +must_haves: + truths: + - "Empty input returns empty list" + - "Single entry produces 1 consolidated row with 1 location" + - "3 entries with same key produce 1 row with 3 locations" + - "Entries with different keys remain separate rows" + - "Key matching is case-insensitive" + - "MakeKey produces expected pipe-delimited format" + - "10-row input with 3 duplicate pairs produces 7 rows" + - "LocationCount matches Locations.Count" + - "IsHighPrivilege and IsExternalUser are preserved from first entry" + - "Existing solution builds with no compilation errors" + artifacts: + - path: "SharepointToolbox.Tests/Helpers/PermissionConsolidatorTests.cs" + provides: "Unit tests for PermissionConsolidator" + min_lines: 120 + key_links: + - from: "SharepointToolbox.Tests/Helpers/PermissionConsolidatorTests.cs" + to: "SharepointToolbox/Core/Helpers/PermissionConsolidator.cs" + via: "calls Consolidate and MakeKey (internal via InternalsVisibleTo)" + pattern: "PermissionConsolidator\\.(Consolidate|MakeKey)" + - from: "SharepointToolbox.Tests/Helpers/PermissionConsolidatorTests.cs" + to: "SharepointToolbox/Core/Models/UserAccessEntry.cs" + via: "constructs test UserAccessEntry instances" + pattern: "new UserAccessEntry" +--- + + +Create comprehensive unit tests for the PermissionConsolidator service and verify the full solution builds cleanly. + +Purpose: Prove that the consolidation logic handles all edge cases (empty input, single entry, merging, case-insensitivity, the 10-row/7-output scenario from requirements) and that adding the new files does not break existing code. + +Output: One test file with 10 test methods covering all RPT-04 test requirements, plus a clean full-solution build. + + + +@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/dev/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/15-consolidation-data-model/15-CONTEXT.md +@.planning/phases/15-consolidation-data-model/15-RESEARCH.md +@.planning/phases/15-consolidation-data-model/15-01-SUMMARY.md + + + +```csharp +// SharepointToolbox/Core/Models/LocationInfo.cs +namespace SharepointToolbox.Core.Models; +public record LocationInfo(string SiteUrl, string SiteTitle, string ObjectTitle, string ObjectUrl, string ObjectType); + +// SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs +namespace SharepointToolbox.Core.Models; +public record ConsolidatedPermissionEntry( + string UserDisplayName, string UserLogin, string PermissionLevel, + AccessType AccessType, string GrantedThrough, + bool IsHighPrivilege, bool IsExternalUser, + IReadOnlyList Locations +) { public int LocationCount => Locations.Count; } + +// SharepointToolbox/Core/Helpers/PermissionConsolidator.cs +namespace SharepointToolbox.Core.Helpers; +public static class PermissionConsolidator +{ + internal static string MakeKey(UserAccessEntry entry); + public static IReadOnlyList Consolidate(IReadOnlyList entries); +} + +// SharepointToolbox/Core/Models/UserAccessEntry.cs +namespace SharepointToolbox.Core.Models; +public enum AccessType { Direct, Group, Inherited } +public record UserAccessEntry( + string UserDisplayName, string UserLogin, string SiteUrl, string SiteTitle, + string ObjectType, string ObjectTitle, string ObjectUrl, + string PermissionLevel, AccessType AccessType, string GrantedThrough, + bool IsHighPrivilege, bool IsExternalUser +); +``` + + + + + + + + + + Task 1: Create PermissionConsolidatorTests with all 10 test cases + SharepointToolbox.Tests/Helpers/PermissionConsolidatorTests.cs + + - RPT-04-a: Empty input returns empty list + - RPT-04-b: Single entry produces 1 consolidated row with 1 location + - RPT-04-c: 3 entries with same key (same UserLogin+PermissionLevel+AccessType+GrantedThrough, different sites) produce 1 row with 3 locations + - RPT-04-d: Entries with different keys (different PermissionLevel) remain as separate rows + - RPT-04-e: Case-insensitive key — "ALICE@contoso.com" and "alice@contoso.com" merge into same group + - RPT-04-f: MakeKey produces pipe-delimited format "userlogin|permissionlevel|accesstype|grantedthrough" (all lowercase strings) + - RPT-04-g: 10-row input with 3 duplicate pairs produces exactly 7 consolidated rows + - RPT-04-h: LocationCount property equals Locations.Count for a merged entry + - RPT-04-i: IsHighPrivilege=true and IsExternalUser=true from first entry are preserved in consolidated result + - RPT-04-j: Full solution builds cleanly (verified by build command, not a test method) + + +Create `PermissionConsolidatorTests.cs` in `SharepointToolbox.Tests/Helpers/`. + +Follow existing test conventions from `PermissionLevelMappingTests.cs`: +- Namespace: `SharepointToolbox.Tests.Helpers` +- Use `[Fact]` for each test +- PascalCase method names with descriptive names + +Include a private helper factory method to reduce boilerplate: + +```csharp +private static UserAccessEntry MakeEntry( + string userLogin = "alice@contoso.com", + string siteUrl = "https://contoso.sharepoint.com/sites/hr", + string siteTitle = "HR Site", + string objectType = "List", + string objectTitle = "Documents", + string objectUrl = "https://contoso.sharepoint.com/sites/hr/Documents", + string permissionLevel = "Contribute", + AccessType accessType = AccessType.Direct, + string grantedThrough = "Direct Permissions", + string userDisplayName = "Alice Smith", + bool isHighPrivilege = false, + bool isExternalUser = false) +{ + return new UserAccessEntry( + userDisplayName, userLogin, siteUrl, siteTitle, + objectType, objectTitle, objectUrl, + permissionLevel, accessType, grantedThrough, + isHighPrivilege, isExternalUser); +} +``` + +**Test implementations:** + +**RPT-04-a** `Consolidate_EmptyInput_ReturnsEmptyList`: +- `var result = PermissionConsolidator.Consolidate(Array.Empty());` +- Assert: `result` is empty. + +**RPT-04-b** `Consolidate_SingleEntry_ReturnsOneRowWithOneLocation`: +- Create 1 entry via `MakeEntry()`. +- Assert: result count is 1, result[0].Locations.Count is 1, result[0].UserLogin matches. + +**RPT-04-c** `Consolidate_ThreeEntriesSameKey_ReturnsOneRowWithThreeLocations`: +- Create 3 entries with same UserLogin/PermissionLevel/AccessType/GrantedThrough but different SiteUrl/SiteTitle. +- Assert: result count is 1, result[0].Locations.Count is 3. + +**RPT-04-d** `Consolidate_DifferentKeys_RemainSeparateRows`: +- Create 2 entries with same UserLogin but different PermissionLevel ("Contribute" vs "Full Control"). +- Assert: result count is 2. + +**RPT-04-e** `Consolidate_CaseInsensitiveKey_MergesCorrectly`: +- Create 2 entries: one with UserLogin "ALICE@CONTOSO.COM" and one with "alice@contoso.com", same other key fields. +- Assert: result count is 1, result[0].Locations.Count is 2. + +**RPT-04-f** `MakeKey_ProducesPipeDelimitedLowercaseFormat`: +- Create entry with UserLogin="Alice@Contoso.com", PermissionLevel="Full Control", AccessType=Direct, GrantedThrough="Direct Permissions". +- Call `PermissionConsolidator.MakeKey(entry)`. +- Assert: result equals "alice@contoso.com|full control|Direct|direct permissions" (note: enum ToString() preserves case). + +**RPT-04-g** `Consolidate_TenRowsWithThreeDuplicatePairs_ReturnsSevenRows`: +- Build exactly 10 entries: + - 3 entries for alice@contoso.com / Contribute / Direct / "Direct Permissions" (different sites) -> merges to 1 + - 2 entries for bob@contoso.com / Full Control / Group / "SharePoint Group: Owners" (different sites) -> merges to 1 + - 2 entries for carol@contoso.com / Read / Inherited / "Inherited Permissions" (different sites) -> merges to 1 + - 1 entry for alice@contoso.com / Full Control / Direct / "Direct Permissions" (different key from alice's Contribute) + - 1 entry for dave@contoso.com / Contribute / Direct / "Direct Permissions" + - 1 entry for eve@contoso.com / Read / Direct / "Direct Permissions" +- Assert: result.Count is 7 (3 pairs merged to 1 each = 3, plus 4 unique = 7). + +**RPT-04-h** `Consolidate_MergedEntry_LocationCountMatchesLocationsCount`: +- Create 3 entries with same key. +- Assert: result[0].LocationCount == result[0].Locations.Count && result[0].LocationCount == 3. + +**RPT-04-i** `Consolidate_PreservesIsHighPrivilegeAndIsExternalUser`: +- Create 2 entries with same key, first has IsHighPrivilege=true, IsExternalUser=true. +- Assert: consolidated result has IsHighPrivilege=true and IsExternalUser=true. + +Use `using SharepointToolbox.Core.Helpers;` and `using SharepointToolbox.Core.Models;`. + + + cd C:/Users/dev/Documents/projets/Sharepoint && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionConsolidatorTests" --no-restore -v n 2>&1 | tail -20 + + All 9 test methods pass (RPT-04-a through RPT-04-i). Tests cover empty input, single entry, merging, separate keys, case insensitivity, MakeKey format, the 10-row scenario, LocationCount, and preserved flags. + + + + Task 2: Verify full solution build (existing exports unchanged) + + +Run a full solution build to confirm: +1. The three new files (LocationInfo.cs, ConsolidatedPermissionEntry.cs, PermissionConsolidator.cs) compile. +2. The test file (PermissionConsolidatorTests.cs) compiles. +3. All existing code — especially `UserAccessHtmlExportService`, `HtmlExportService`, and `UserAccessAuditService` — compiles without modification. +4. No existing tests are broken. + +Run: `dotnet build` (full solution) and then `dotnet test` (all tests). + +This satisfies success criterion 4: "Existing HTML export services compile and produce identical output when consolidation is not applied (opt-in, defaults off)." Since no existing files were modified and the new code is opt-in only, existing behavior is unchanged by definition. + +If the build or any existing test fails, investigate and fix — the new files must not introduce any regressions. + + + cd C:/Users/dev/Documents/projets/Sharepoint && dotnet build -v q 2>&1 | tail -5 && dotnet test --no-build -v n 2>&1 | tail -10 + + Full solution builds with 0 errors. All existing tests plus new PermissionConsolidatorTests pass. No existing files modified. + + + + + +All tests pass including the new PermissionConsolidatorTests: +```bash +cd C:/Users/dev/Documents/projets/Sharepoint && dotnet test -v n +``` + +Specific verification of the 10-row scenario (success criterion 3): +```bash +cd C:/Users/dev/Documents/projets/Sharepoint && dotnet test --filter "FullyQualifiedName~TenRowsWithThreeDuplicatePairs" -v n +``` + + + +- PermissionConsolidatorTests.cs exists with 9 [Fact] test methods covering RPT-04-a through RPT-04-i +- All 9 tests pass +- The 10-row input test produces exactly 7 output rows +- Full solution build succeeds with 0 errors (RPT-04-j) +- All pre-existing tests continue to pass + + + +After completion, create `.planning/phases/15-consolidation-data-model/15-02-SUMMARY.md` +