yavsc/src/Yavsc.Api.Client/BlogApiClient.cs
Paul Schneider 3fb5f40acb
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.

The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).

Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish  body { publish: bool }
  Returns 204 on success, 404 when the post doesn't exist,
  Challenge() (401) when the caller is not the author
  (EditPermission gate). Idempotent: PUT because the
  resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
  factored out of the existing
  Modify(BlogPostEditViewModel) inline toggle, so the new
  endpoint reuses the same BlogSpotPublication row logic
  (add row if missing on publish=true, remove row if
  present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
  service after each Index/Details fetch — a single bulk
  lookup, not N+1 — and surfaces through the wire JSON
  so PostIt can show the current state without a follow-up
  request.
- ApplicationUser nav properties (Posts, Book,
  DeviceDeclaration, Connections, Circles, BlackList,
  Rooms, RoomAccess, Membership, BlogComments) now carry
  BOTH [JsonIgnore] (Newtonsoft) and
  [System.Text.Json.Serialization.JsonIgnore] so the
  Yavsc.Blogs test fixture (System.Text.Json) stops
  exploding on object cycles when serialising
  BlogPost.Author.Posts.Author.Posts. Production
  (Yavsc.Org, NewtonsoftJson) was already safe via the
  Newtonsoft-only attribute; this commit just makes the
  Yavsc.Blogs side consistent.

Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
  new endpoint.

DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
  field; serialised as a plain bool in JSON.

UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
  mirrors the existing DraftTitle/DraftArticle pattern;
  hydrated from SelectedPost.IsPublished on selection
  change. TogglePublish command pushes the new state to
  SetPublishAsync and updates both the buffer and the
  selected post locally so the UI reflects the change
  without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
  bound to DraftIsPublished TwoWay and wired to
  TogglePublishCommand. The toggle is its own action
  (not part of Save), matching the wire contract.

Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
    * PUT publish=true returns 204 and IsPublished is true
      in the next GET
    * PUT publish=false clears IsPublished
    * PUT on an unknown post returns 404
    * PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
  app.UseDeveloperExceptionPage() so 500s in tests
  surface a real stack trace instead of an empty
  InternalServerError body — much easier to diagnose
  future regressions.

Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
  rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
  in the admin web Yavsc (the Org UI already edits Publish
  inline; no work needed there).
2026-08-18 16:10:45 +01:00

84 lines
3.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Blogspot;
namespace Yavsc.Api.Client;
/// <summary>
/// High-level client for the Blog subsystem of the Yavsc API
/// (deployed at <c>https://blogs.pschneider.fr</c>). All transport
/// concerns — base URL, JSON serialisation, Bearer auth, silent
/// refresh on 401, request body shaping — are delegated to
/// <see cref="YavscApiClient"/>, which lives in the consuming
/// application (PostIt). This class is a thin DTO↔path mapper,
/// nothing more.
///
/// <para><b>URL convention.</b> <see cref="YavscApiClient"/>'s
/// <c>BaseAddress</c> already terminates with <c>/api/v1/</c>
/// (see <c>Settings.ApiUrl</c>). The path prefix below is
/// therefore <i>relative</i> to that version segment: a prefix of
/// <c>"blog"</c> resolves to <c>…/api/v1/blog</c>, which matches
/// the <c>[Route(APIPrefix + "/blog")]</c> attribute on
/// <c>Yavsc.Blogs.Controllers.BlogApiController</c>. Do not
/// re-include the <c>api/</c> segment here — that produced 404s
/// in the past (see commit "PostIt: fix blog API double-prefix").</para>
///
/// The class is intentionally non-IDisposable: it does not own the
/// <see cref="YavscApiClient"/> it depends on. Lifetimes are managed
/// by the consumer (typically a singleton service registered with
/// the application).
/// </summary>
public sealed class BlogApiClient
{
private const string DefaultPathPrefix = "blog";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly string _pathPrefix;
public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
// e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
_baseAddress = new Uri(blogsBaseAddress);
api.Http.BaseAddress = _baseAddress;
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}
public Task<List<BlogPostDto>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync<List<BlogPostDto>>(
HttpMethod.Get,
$"{_pathPrefix}?start={start}&take={take}",
ct: ct);
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public Task<BlogPostDto?> CreatePostAsync(BlogPostDto post, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
/// <summary>
/// Set a post's publication state. <c>true</c> publishes
/// it (visible to anonymous readers via
/// <c>PermissionHandler.IsPublic</c>); <c>false</c> takes
/// it back to draft. Idempotent: the resulting state
/// matches the call, regardless of the previous state.
/// </summary>
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
body: new { publish }, ct: ct);
}