---
phase: 07-user-access-audit
plan: 03
type: execute
wave: 2
depends_on: ["07-01"]
files_modified:
  - SharepointToolbox/Services/GraphUserSearchService.cs
autonomous: true
requirements:
  - UACC-01
must_haves:
  truths:
    - "GraphUserSearchService queries Microsoft Graph /users endpoint with $filter for displayName/mail startsWith"
    - "Service returns GraphUserResult records with DisplayName, UPN, and Mail"
    - "Service handles empty queries and returns empty list"
    - "Service uses existing GraphClientFactory for authentication"
  artifacts:
    - path: "SharepointToolbox/Services/GraphUserSearchService.cs"
      provides: "Implementation of IGraphUserSearchService for people-picker autocomplete"
      contains: "class GraphUserSearchService"
  key_links:
    - from: "SharepointToolbox/Services/GraphUserSearchService.cs"
      to: "SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs"
      via: "Constructor injection, CreateClientAsync call"
      pattern: "CreateClientAsync"
---

<objective>
Implement GraphUserSearchService that queries Microsoft Graph API to search tenant users by name or email. Powers the people-picker autocomplete in the audit tab.

Purpose: Enables administrators to find and select tenant users by typing partial names/emails, rather than typing exact login names manually.
Output: GraphUserSearchService.cs
</objective>

<execution_context>
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-01-SUMMARY.md

<interfaces>
<!-- From 07-01: Interface to implement -->
From SharepointToolbox/Services/IGraphUserSearchService.cs:
```csharp
public interface IGraphUserSearchService
{
    Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
        string clientId, string query, int maxResults = 10,
        CancellationToken ct = default);
}

public record GraphUserResult(string DisplayName, string UserPrincipalName, string? Mail);
```

<!-- Existing auth infrastructure -->
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
```csharp
public class GraphClientFactory
{
    public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct);
}
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Implement GraphUserSearchService</name>
  <files>SharepointToolbox/Services/GraphUserSearchService.cs</files>
  <action>
    Create `SharepointToolbox/Services/GraphUserSearchService.cs`:

    ```csharp
    using SharepointToolbox.Infrastructure.Auth;

    namespace SharepointToolbox.Services;

    /// <summary>
    /// Searches tenant users via Microsoft Graph API.
    /// Used by the people-picker autocomplete in the User Access Audit tab.
    /// </summary>
    public class GraphUserSearchService : IGraphUserSearchService
    {
        private readonly GraphClientFactory _graphClientFactory;

        public GraphUserSearchService(GraphClientFactory graphClientFactory)
        {
            _graphClientFactory = graphClientFactory;
        }

        public async Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
            string clientId,
            string query,
            int maxResults = 10,
            CancellationToken ct = default)
        {
            if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
                return Array.Empty<GraphUserResult>();

            var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);

            // Use $filter with startsWith on displayName and mail.
            // Graph API requires ConsistencyLevel=eventual for advanced queries.
            var escapedQuery = query.Replace("'", "''");
            var response = await graphClient.Users.GetAsync(config =>
            {
                config.QueryParameters.Filter =
                    $"startsWith(displayName,'{escapedQuery}') or startsWith(mail,'{escapedQuery}') or startsWith(userPrincipalName,'{escapedQuery}')";
                config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail" };
                config.QueryParameters.Top = maxResults;
                config.QueryParameters.Orderby = new[] { "displayName" };
                config.Headers.Add("ConsistencyLevel", "eventual");
                config.QueryParameters.Count = true;
            }, ct);

            if (response?.Value is null)
                return Array.Empty<GraphUserResult>();

            return response.Value
                .Select(u => new GraphUserResult(
                    DisplayName: u.DisplayName ?? u.UserPrincipalName ?? "Unknown",
                    UserPrincipalName: u.UserPrincipalName ?? string.Empty,
                    Mail: u.Mail))
                .ToList();
        }
    }
    ```

    Design notes:
    - Minimum 2 characters before searching (prevents overly broad queries)
    - Uses startsWith filter on displayName, mail, and UPN for broad matching
    - Single quotes in query are escaped to prevent OData injection
    - ConsistencyLevel=eventual header required for startsWith filter on directory objects
    - Count=true is required alongside ConsistencyLevel=eventual
    - Returns max 10 results by default (people picker dropdown)
    - Uses existing GraphClientFactory which handles MSAL token acquisition
  </action>
  <verify>
    <automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
  </verify>
  <done>GraphUserSearchService.cs compiles, implements IGraphUserSearchService, uses GraphClientFactory for auth, queries Graph /users with startsWith filter, returns GraphUserResult list.</done>
</task>

</tasks>

<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- GraphUserSearchService implements IGraphUserSearchService
- Uses GraphClientFactory.CreateClientAsync (not raw HTTP)
- Handles empty/short queries gracefully (returns empty list)
- Filter uses startsWith on displayName, mail, and UPN
</verification>

<success_criteria>
The Graph people search service is implemented: given a partial name/email query, it returns matching tenant users via Microsoft Graph API. Ready for ViewModel consumption in 07-04 (people picker debounced autocomplete).
</success_criteria>

<output>
After completion, create `.planning/phases/07-user-access-audit/07-03-SUMMARY.md`
</output>
