feat/postit-acl #32
7 changed files with 259 additions and 128 deletions
refactor(postit): split IContactService from IUserDirectory
IContactService used to be the catch-all for "people you can reach
from PostIt": on mobile it read the device-local address book, on
desktop it queried the central /api/user-search endpoint and merged
both worlds into a single ContactDto (a flat Email field, an
ObservableCollection cache, a SearchAsync method). Two unrelated
flows under the same name, with a wire shape (Email) silently
flattening the mobile provider's multi-email list.
Split into two interfaces, each with a single responsibility:
- IContactService: device-local address book only. Mobile provider
reads MAUI Essentials Contacts.Default and carries the full email
list per contact. Desktop provider is an honest stub returning an
empty list — the desktop has no local address book, and inviting
external people from desktop is a separate flow (manual email
entry + invitation endpoint) that doesn't belong here.
- IUserDirectory: central Yavsc user directory, the only consumer
of /api/user-search. Both Desktop and Mobile providers delegate
to UserSearchClient; the platform split exists so future
platform-specific sources (offline cache, directory-scoped
providers) can plug in without disturbing consumers.
ContactDto restores IReadOnlyList<string> Emails (the flat Email
from d0e0f4c1 was a regression that matched the wire shape of
/api/user-search at the cost of the mobile provider's per-contact
list). UserSummary is a separate platform-neutral record that
mirrors the server's UserSearchResultDto without leaking transport
concerns.
App.axaml.cs registers both interfaces as singletons.
Build + 51/51 PostIt.Tests green. No UI consumer yet — these
interfaces are still plomberie; the ViewModel that joins them for
the "add to a circle" / "invite someone" flows is a follow-up.
commit
04a31709a2
|
|
@ -60,7 +60,8 @@ public partial class App : Application
|
|||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
var contactService = new ContactService(userSearchClient);
|
||||
var contactService = new ContactService();
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
|
|
@ -91,6 +92,7 @@ public partial class App : Application
|
|||
services.AddSingleton(blogAclClient);
|
||||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton<IContactService>(contactService);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddTransient<MainPageViewModel>();
|
||||
services.AddTransient<HomePageViewModel>();
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
|
|
|
|||
|
|
@ -1,72 +1,36 @@
|
|||
#if !ANDROID && !IOS
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Desktop implementation of <see cref="IContactService"/> backed
|
||||
/// by the central <c>/api/user-search</c> endpoint
|
||||
/// (<see cref="UserSearchClient"/>).
|
||||
/// Desktop stub for <see cref="IContactService"/>.
|
||||
///
|
||||
/// <para>Desktop has no equivalent of the mobile address book
|
||||
/// (no Contacts.Default, no CardDAV out of the box), so the
|
||||
/// address book is built on demand from the Yavsc user table.
|
||||
/// Results are accumulated in an in-memory cache exposed as
|
||||
/// <see cref="Contacts"/>; the cache is process-lifetime only
|
||||
/// — there's no persistence layer.</para>
|
||||
/// <para>The desktop has no equivalent of the mobile address
|
||||
/// book (no <c>Contacts.Default</c>, no CardDAV out of the
|
||||
/// box). Rather than synthesise a list from a different
|
||||
/// source, this provider returns an empty list and lets the
|
||||
/// UI render an honest "no local contacts on this platform"
|
||||
/// message.</para>
|
||||
///
|
||||
/// <para>This is the consumer that closes the loop with the
|
||||
/// user-search endpoint landed on the server in commit 6
|
||||
/// (<c>b3056f1c</c>) and the client in commit 7
|
||||
/// (<c>6e7e0414</c>).</para>
|
||||
/// <para>If desktop users want to invite people who aren't
|
||||
/// Yavsc members, that flow goes through a separate path
|
||||
/// (manual email entry + invitation endpoint) — not through
|
||||
/// <see cref="IContactService"/>. Finding existing Yavsc
|
||||
/// members is <see cref="IUserDirectory"/>'s job, not this
|
||||
/// one's.</para>
|
||||
///
|
||||
/// <para>Future CardDAV / Google Contacts / Exchange
|
||||
/// providers can plug in here as additional
|
||||
/// <see cref="IContactService"/> implementations selected
|
||||
/// from DI by configuration.</para>
|
||||
/// </summary>
|
||||
public sealed class ContactService : IContactService
|
||||
{
|
||||
private readonly UserSearchClient _client;
|
||||
|
||||
public ObservableCollection<ContactDto> Contacts { get; } = new();
|
||||
|
||||
public ContactService(UserSearchClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<ContactDto>>(Contacts.ToArray());
|
||||
|
||||
public async Task SearchAsync(string query, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
// Clear the cache to mirror an empty result. The
|
||||
// address-book UX treats an empty query as "start
|
||||
// over".
|
||||
Contacts.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
|
||||
if (results is null) return;
|
||||
|
||||
// Append the search results to the cache. We don't
|
||||
// de-dupe across searches — the simplest behaviour, and
|
||||
// matches what users expect from a search panel ("show
|
||||
// me what came back"). Callers wanting a single list
|
||||
// can re-render Contacts on the next query.
|
||||
foreach (var u in results)
|
||||
{
|
||||
Contacts.Add(new ContactDto(
|
||||
Id: u.Id,
|
||||
DisplayName: u.FullName ?? u.UserName,
|
||||
Email: u.Email));
|
||||
}
|
||||
}
|
||||
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
#if ANDROID || IOS
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.ApplicationModel.Communication;
|
||||
|
|
@ -11,22 +10,23 @@ using Microsoft.Maui.Devices;
|
|||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Mobile implementation backed by MAUI Essentials Contacts.Default.
|
||||
/// Mobile implementation backed by MAUI Essentials
|
||||
/// <c>Contacts.Default</c>.
|
||||
///
|
||||
/// Compiled only for ANDROID and IOS. On desktop targets, see
|
||||
/// ContactService.Desktop.cs (the stub that wins at compile time).
|
||||
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
|
||||
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
|
||||
/// compile time).</para>
|
||||
///
|
||||
/// Note: at runtime, this class throws
|
||||
/// NotImplementedInReferenceAssemblyException unless the host
|
||||
/// application project also references the platform-specific
|
||||
/// Microsoft.Maui.Essentials implementation (typically the
|
||||
/// PostIt.Android project). On iOS the same is required via
|
||||
/// PostIt.iOS. On desktop the stub is used and this file is excluded.
|
||||
/// <para>Note: at runtime, this class throws
|
||||
/// <c>NotImplementedInReferenceAssemblyException</c> unless
|
||||
/// the host application project also references the
|
||||
/// platform-specific Microsoft.Maui.Essentials implementation
|
||||
/// (typically <c>PostIt.Android</c>). On iOS the same is
|
||||
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
|
||||
/// and this file is excluded.</para>
|
||||
/// </summary>
|
||||
public sealed class ContactService : IContactService
|
||||
{
|
||||
public ObservableCollection<ContactDto> Contacts { get; } = new();
|
||||
|
||||
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
|
||||
|
|
@ -41,18 +41,24 @@ public sealed class ContactService : IContactService
|
|||
var contacts = await Contacts.Default.GetAllAsync();
|
||||
if (contacts is null) return Array.Empty<ContactDto>();
|
||||
|
||||
// Flatten the per-contact email list down to one
|
||||
// primary email. The platform-neutral ContactDto only
|
||||
// carries one; the use case ("invite / add to a
|
||||
// circle") only needs one. The first non-empty entry
|
||||
// wins.
|
||||
Contacts.Clear();
|
||||
// Carry the per-contact email list as-is. A real
|
||||
// device contact can carry several addresses (home /
|
||||
// work / other); the UI use case ("invite / add to a
|
||||
// circle") can then decide which address to use, or
|
||||
// let the user pick. The platform-neutral ContactDto
|
||||
// shape is intentionally richer than the Yavsc
|
||||
// directory's single-Email shape — the two flows
|
||||
// answer different questions.
|
||||
var result = new List<ContactDto>(contacts.Count);
|
||||
foreach (var c in contacts)
|
||||
{
|
||||
var email = FlattenPrimaryEmail(c.Emails);
|
||||
Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email));
|
||||
var emails = ExtractEmails(c.Emails);
|
||||
result.Add(new ContactDto(
|
||||
c.Id,
|
||||
c.DisplayName ?? string.Empty,
|
||||
emails));
|
||||
}
|
||||
return Contacts.ToArray();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -61,21 +67,16 @@ public sealed class ContactService : IContactService
|
|||
}
|
||||
}
|
||||
|
||||
public Task SearchAsync(string query, CancellationToken ct = default)
|
||||
=> throw new PlatformNotSupportedException(
|
||||
"SearchAsync is not supported on mobile — use GetDeviceContactsAsync " +
|
||||
"to load the local address book. The network search lives on the " +
|
||||
"desktop service, which queries the central user-search endpoint.");
|
||||
|
||||
private static string? FlattenPrimaryEmail(IEnumerable<EmailAddress>? emails)
|
||||
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
|
||||
{
|
||||
if (emails is null) return null;
|
||||
if (emails is null) return Array.Empty<string>();
|
||||
var list = new List<string>();
|
||||
foreach (var e in emails)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.EmailAddress))
|
||||
return e.EmailAddress;
|
||||
list.Add(e.EmailAddress);
|
||||
}
|
||||
return null;
|
||||
return list;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,61 +1,57 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over device contact providers (MAUI Essentials on
|
||||
/// mobile, the central /api/user-search endpoint on desktop).
|
||||
/// Abstraction over the device-local address book. Used by
|
||||
/// the "invite someone" flow to enumerate people the user
|
||||
/// already has in their phone — including people who have
|
||||
/// never heard of Yavsc.
|
||||
///
|
||||
/// Implementations live next to this file in platform-conditional
|
||||
/// source files: ContactService.Mobile.cs (ANDROID/IOS) and
|
||||
/// ContactService.Desktop.cs (everything else).
|
||||
/// <para>Distinct from <see cref="IUserDirectory"/>, which
|
||||
/// reads the central Yavsc user table. A device contact may
|
||||
/// not have a Yavsc account; a directory entry always does.
|
||||
/// The two are exposed as separate interfaces so a UI that
|
||||
/// needs both can take both by constructor injection and
|
||||
/// present them under separate sections (e.g. "Contacts from
|
||||
/// your phone" vs "Yavsc members").</para>
|
||||
///
|
||||
/// <para>Implementations live next to this file in
|
||||
/// platform-conditional source files:
|
||||
/// <c>ContactService.Mobile.cs</c> (ANDROID/IOS) and
|
||||
/// <c>ContactService.Desktop.cs</c> (everything else). On
|
||||
/// desktop the implementation is a stub that returns an
|
||||
/// empty list: the desktop has no equivalent of the mobile
|
||||
/// address book, and inviting from a desktop is a separate
|
||||
/// flow.</para>
|
||||
/// </summary>
|
||||
public interface IContactService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the contacts known so far. On mobile this is the
|
||||
/// full device address book (after permission grant); on
|
||||
/// desktop this is the in-memory cache populated by previous
|
||||
/// <see cref="SearchAsync"/> calls — empty until the user
|
||||
/// has searched for something.
|
||||
/// Read the device address book. Returns the contacts
|
||||
/// known to the local provider; on desktop (no local
|
||||
/// provider) this is always an empty list.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// On desktop: hits <c>GET /api/user-search?q=…</c> and
|
||||
/// appends matching users to the in-memory cache exposed via
|
||||
/// <see cref="Contacts"/>. On mobile: throws
|
||||
/// <see cref="PlatformNotSupportedException"/> — the mobile
|
||||
/// provider uses the device-local address book, not a
|
||||
/// network search.
|
||||
/// </summary>
|
||||
Task SearchAsync(string query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Live view of the in-memory contact cache. UI binds to
|
||||
/// this directly for a \"search results\" panel; on mobile
|
||||
/// implementations this is populated eagerly by
|
||||
/// <see cref="GetDeviceContactsAsync"/>.
|
||||
/// </summary>
|
||||
ObservableCollection<ContactDto> Contacts { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-neutral contact DTO. Source-of-truth shape for the UI
|
||||
/// layer; concrete providers (MAUI Essentials on mobile,
|
||||
/// UserSearchClient on desktop) map to this type.
|
||||
/// Platform-neutral contact DTO. Source-of-truth shape for
|
||||
/// the UI layer; concrete providers (MAUI Essentials on
|
||||
/// mobile) map to this type.
|
||||
///
|
||||
/// <para><c>Email</c> is a single string on purpose: the central
|
||||
/// search endpoint returns one email per user, and the UI use
|
||||
/// case is \"pick someone to invite / add to a circle\", which
|
||||
/// never needs more than one. Multi-email contacts on mobile
|
||||
/// flatten to the primary address (first non-empty).</para>
|
||||
/// <para><c>Emails</c> is a list on purpose: a real device
|
||||
/// contact may carry several addresses (home / work / other).
|
||||
/// The UI use case ("invite / add to a circle") can then
|
||||
/// decide which address to use, or let the user pick. This
|
||||
/// is intentionally richer than the Yavsc directory's
|
||||
/// single-<c>Email</c> shape — the two flows answer different
|
||||
/// questions and shouldn't be flattened onto the same
|
||||
/// wire.</para>
|
||||
/// </summary>
|
||||
public sealed record ContactDto(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string? Email);
|
||||
IReadOnlyList<string> Emails);
|
||||
|
|
|
|||
67
src/PostIt/PostIt/Services/IUserDirectory.cs
Normal file
67
src/PostIt/PostIt/Services/IUserDirectory.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the central Yavsc user directory. Used by
|
||||
/// the "add to a circle" flow to find Yavsc users by display
|
||||
/// name or email.
|
||||
///
|
||||
/// <para>Distinct from <see cref="IContactService"/>, which
|
||||
/// reads the device-local address book. A Yavsc user
|
||||
/// directory entry is always a registered account; a device
|
||||
/// contact may be anyone in the user's phone — including
|
||||
/// people who have never heard of Yavsc.</para>
|
||||
///
|
||||
/// <para>Implementations live next to this file in
|
||||
/// platform-conditional source files:
|
||||
/// <c>UserDirectory.Desktop.cs</c> and
|
||||
/// <c>UserDirectory.Mobile.cs</c>. Both currently delegate to
|
||||
/// <c>UserSearchClient</c> (the central <c>/api/user-search</c>
|
||||
/// endpoint); the split exists so future platform-specific
|
||||
/// sources (offline cache, directory-scoped providers) can be
|
||||
/// plugged in without disturbing the consumer.</para>
|
||||
/// </summary>
|
||||
public interface IUserDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Search the directory by display name (substring) and/or
|
||||
/// email (exact).
|
||||
/// </summary>
|
||||
/// <param name="query">Substring filter on the user's
|
||||
/// display name. Empty or whitespace short-circuits to an
|
||||
/// empty list (matches the client UX of "type to search",
|
||||
/// not "show me a directory").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A flat list of matching directory entries.
|
||||
/// Never null; may be empty.</returns>
|
||||
Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-neutral summary of a Yavsc directory entry. Mirrors
|
||||
/// the wire shape of <c>/api/user-search</c> (see
|
||||
/// <c>UserSearchResultDto</c>) but expressed in terms that
|
||||
/// don't leak transport concerns.
|
||||
///
|
||||
/// <para>Kept as a record on purpose: directory entries are
|
||||
/// immutable snapshots from the server, so structural equality
|
||||
/// makes "did the user already pick this one?" trivial.</para>
|
||||
/// </summary>
|
||||
public sealed record UserSummary(
|
||||
string Id,
|
||||
string UserName,
|
||||
string? FullName,
|
||||
string? Avatar,
|
||||
string? Email)
|
||||
{
|
||||
/// <summary>
|
||||
/// Convenience for "what to show in a picker". Falls back
|
||||
/// to <see cref="UserName"/> when <see cref="FullName"/>
|
||||
/// is null or empty.
|
||||
/// </summary>
|
||||
public string DisplayName =>
|
||||
string.IsNullOrWhiteSpace(FullName) ? UserName : FullName;
|
||||
}
|
||||
52
src/PostIt/PostIt/Services/UserDirectory.Desktop.cs
Normal file
52
src/PostIt/PostIt/Services/UserDirectory.Desktop.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#if !ANDROID && !IOS
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Desktop implementation of <see cref="IUserDirectory"/>.
|
||||
/// Delegates to the central <c>/api/user-search</c> endpoint
|
||||
/// via <see cref="UserSearchClient"/>.
|
||||
///
|
||||
/// <para>The desktop has no device-local address book, so the
|
||||
/// "add to a circle" flow on desktop is Yavsc-users-only.
|
||||
/// Inviting someone who doesn't have a Yavsc account from
|
||||
/// desktop is a separate feature (manual email entry +
|
||||
/// invitation endpoint) and lives outside this interface.</para>
|
||||
/// </summary>
|
||||
public sealed class UserDirectory : IUserDirectory
|
||||
{
|
||||
private readonly UserSearchClient _client;
|
||||
|
||||
public UserDirectory(UserSearchClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
|
||||
string query, CancellationToken ct = default)
|
||||
{
|
||||
// UserSearchClient already short-circuits on empty
|
||||
// queries, but do it here too so the contract is
|
||||
// obvious to anyone reading IUserDirectory alone
|
||||
// without having to chase the client wrapper.
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return Array.Empty<UserSummary>();
|
||||
|
||||
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
|
||||
if (results is null) return Array.Empty<UserSummary>();
|
||||
|
||||
return results.Select(u => new UserSummary(
|
||||
Id: u.Id,
|
||||
UserName: u.UserName,
|
||||
FullName: u.FullName,
|
||||
Avatar: u.Avatar,
|
||||
Email: u.Email)).ToList();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
49
src/PostIt/PostIt/Services/UserDirectory.Mobile.cs
Normal file
49
src/PostIt/PostIt/Services/UserDirectory.Mobile.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
#if ANDROID || IOS
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Mobile implementation of <see cref="IUserDirectory"/>.
|
||||
/// Same backing as the desktop provider (the central
|
||||
/// <c>/api/user-search</c> endpoint via
|
||||
/// <see cref="UserSearchClient"/>) — mobile devices have the
|
||||
/// network too, and "add to a circle" needs the same directory
|
||||
/// regardless of platform.
|
||||
///
|
||||
/// <para>The split exists so a future mobile-only provider
|
||||
/// (offline cache, device-local mirror of the user's own
|
||||
/// circles) can be plugged in without touching consumers.</para>
|
||||
/// </summary>
|
||||
public sealed class UserDirectory : IUserDirectory
|
||||
{
|
||||
private readonly UserSearchClient _client;
|
||||
|
||||
public UserDirectory(UserSearchClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
|
||||
string query, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return Array.Empty<UserSummary>();
|
||||
|
||||
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
|
||||
if (results is null) return Array.Empty<UserSummary>();
|
||||
|
||||
return results.Select(u => new UserSummary(
|
||||
Id: u.Id,
|
||||
UserName: u.UserName,
|
||||
FullName: u.FullName,
|
||||
Avatar: u.Avatar,
|
||||
Email: u.Email)).ToList();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Loading…
Add table
Add a link
Reference in a new issue