feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.
Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
the blogs base URL explicitly in its constructor so it doesn't
need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
Same conventions as CircleApiClient.
DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
navigation properties on the server-side Circle (Owner,
Members), which depend on ApplicationUser and other server
types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
reason: the server entity has Target and Allowed navigation
properties the client never needs.
The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.
Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
2026-08-17 23:50:35 +01:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Net.Http;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using Yavsc.Api.Client.Dtos;
|
|
|
|
|
|
|
|
|
|
namespace Yavsc.Api.Client;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// HTTP client for <c>/api/circle</c> on the Yavsc Blogs server.
|
|
|
|
|
///
|
|
|
|
|
/// <para>Same conventions as <see cref="BlogApiClient"/>: all
|
|
|
|
|
/// transport is delegated to <see cref="YavscApiClient"/>; this
|
|
|
|
|
/// class only maps paths to DTOs.</para>
|
|
|
|
|
///
|
|
|
|
|
/// <para>The server now (since the BlogAcl fix on this branch)
|
|
|
|
|
/// scopes every read and write to the caller's uid. There is no
|
|
|
|
|
/// way for the client to read or modify another user's circles
|
|
|
|
|
/// — the route will return 404 (not 403) when the circle exists
|
|
|
|
|
/// but belongs to someone else, to avoid leaking its existence.</para>
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CircleApiClient
|
|
|
|
|
{
|
|
|
|
|
private const string Path = "circle";
|
|
|
|
|
|
|
|
|
|
private readonly IYavscApiClient _api;
|
|
|
|
|
|
|
|
|
|
public CircleApiClient(IYavscApiClient api, string blogsBaseAddress)
|
|
|
|
|
{
|
|
|
|
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
|
|
|
|
if (string.IsNullOrEmpty(blogsBaseAddress))
|
|
|
|
|
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
|
|
|
|
|
|
|
|
|
|
if (api.Http.BaseAddress is null)
|
|
|
|
|
api.Http.BaseAddress = new Uri(blogsBaseAddress);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<List<CircleDto>> GetMyCirclesAsync(CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync<List<CircleDto>>(HttpMethod.Get, Path, ct: ct);
|
|
|
|
|
|
|
|
|
|
public Task<CircleDto?> GetCircleAsync(long id, CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync<CircleDto?>(HttpMethod.Get, $"{Path}/{id}", ct: ct);
|
|
|
|
|
|
|
|
|
|
public Task<CircleDto?> CreateCircleAsync(CircleDto circle, CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync<CircleDto?>(HttpMethod.Post, Path, body: circle, ct: ct);
|
|
|
|
|
|
|
|
|
|
public Task UpdateCircleAsync(long id, CircleDto circle, CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{id}", body: circle, ct: ct);
|
|
|
|
|
|
|
|
|
|
public Task DeleteCircleAsync(long id, CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
|
feat(acl): server endpoints + client + UI for circle membership
Round-trip out a long-standing hole in the ACL feature: until
this commit a circle on Yavsc was an empty named bucket. You
could create 'Famille' and grant it on a post, but the circle
carried no members, so 'Famille' authorised no one. The MVC
admin controller (Yavsc.Org.Controllers.CircleMembersController)
existed but had no REST counterpart, so PostIt — which only
talks to the Yavsc.Blogs API — had no way to manage membership
at all.
This commit closes that gap end-to-end:
Server (Yavsc.Blogs)
- GET /api/circle/{id}/members list members
- POST /api/circle/{id}/members add a user (body { userId })
- DELETE /api/circle/{id}/members/{userId} remove a user
All three are scoped to caller == circle.OwnerId; non-owned
circles return 404 (not 403) to avoid leaking existence, in
line with the rest of the controller.
- Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the
wire shapes. CircleMemberDto mirrors UserSearchResultDto
minus Email — membership UI doesn't need contact details.
Tests (Yavsc.Blogs.Tests)
- CircleMembersApiTests: 5 [Fact] covering empty list,
add+get, duplicate add → 409, remove, and cross-owner 404.
Test users (alice, bob) are seeded directly through the
in-memory DbContext — the Blogs fixture doesn't stand up
UserManager<ApplicationUser>.
Client (Yavsc.Api.Client)
- CircleMemberDto + 3 methods on CircleApiClient:
GetMembersAsync, AddMemberAsync, RemoveMemberAsync.
All match the server's contract: 404 flattens to null,
409 surfaces as an exception (callers can dedupe beforehand
if they want idempotent behaviour).
UI (PostIt)
- CirclesPageViewModel gains a Members ObservableCollection
that auto-loads on SelectedCircle change (via the partial
setter generated by [ObservableProperty]). Commands:
LoadMembersAsync, OpenAddMember (raises an event the view
subscribes to), OnAddMemberConfirmedAsync (called by the
view when the dialog confirms a selection), RemoveMemberAsync.
409 (already a member) is detected from the exception
message and surfaced as a friendly status rather than an
error — a likely race when the same user gets added twice
through two UI paths.
- CirclesPage layout is now two-pane (circles + editor on the
left, members of the selected circle on the right). The
member pane has an 'Ajouter un membre' button that opens
AddCircleMemberDialog. Code-behind wires the dialog's
Confirmed event back into the VM via an async lambda
wrapper (EventHandler<T> wants void, the VM method is
async Task).
- AddCircleMemberDialog is a ContentPage (light modal, same
pattern as PostAclDialog). Its ViewModel consumes
IUserDirectory — the abstraction introduced by 04a31709
to fix the 'user search should not be IContactService'
confusion. The dialog raises Confirmed with the picked
UserSummary; the host (CirclesPage) is responsible for
calling CircleApiClient.AddMemberAsync.
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: all visible text still hard-coded French.
- XAML accessibility audit of pre-existing pages.
- Avalonia.Headless UI tests of the new navigation flow.
Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests
green (was 15; +5 for CircleMembersApiTests), 44/44
Yavsc.Org.Tests green (no regression).
2026-08-18 14:10:12 +01:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Returns the members of one of the caller's circles.
|
|
|
|
|
/// Returns null when the circle does not exist or is not
|
|
|
|
|
/// owned by the caller (the server scopes the endpoint
|
|
|
|
|
/// with a 404 in either case to avoid leaking existence
|
|
|
|
|
/// — this client flattens that into a null result).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task<List<CircleMemberDto>?> GetMembersAsync(long id, CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync<List<CircleMemberDto>?>(HttpMethod.Get, $"{Path}/{id}/members", ct: ct);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Adds a Yavsc user (resolved client-side via
|
|
|
|
|
/// <c>/api/user-search</c>) to one of the caller's
|
|
|
|
|
/// circles. Returns null when the circle does not exist
|
|
|
|
|
/// or is not owned by the caller, or when the target
|
|
|
|
|
/// user does not exist. Throws on 409 (already a
|
|
|
|
|
/// member) — callers that want idempotent behaviour
|
|
|
|
|
/// can swallow the exception or dedupe beforehand.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task AddMemberAsync(long id, string userId, CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync(HttpMethod.Post, $"{Path}/{id}/members",
|
|
|
|
|
body: new { userId }, ct: ct);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Removes a user from one of the caller's circles.
|
|
|
|
|
/// Returns null on success (the server returns 200 OK
|
|
|
|
|
/// with no body) or when the membership does not
|
|
|
|
|
/// exist — both treated as success by the caller.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task RemoveMemberAsync(long id, string userId, CancellationToken ct = default)
|
|
|
|
|
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}/members/{userId}", ct: ct);
|
feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.
Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
the blogs base URL explicitly in its constructor so it doesn't
need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
Same conventions as CircleApiClient.
DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
navigation properties on the server-side Circle (Owner,
Members), which depend on ApplicationUser and other server
types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
reason: the server entity has Target and Allowed navigation
properties the client never needs.
The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.
Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
2026-08-17 23:50:35 +01:00
|
|
|
}
|