feat(post): add Publish toggle for blog posts (no schema change) #35

Merged
notazof merged 1 commit from feat/postit-acl into release/1.0.7 2026-08-18 16:23:24 +01:00
10 changed files with 395 additions and 12 deletions

View file

@ -35,6 +35,18 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string DraftArticle { get; set; } public partial string DraftArticle { get; set; }
/// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the
/// existence of a row in <c>BlogSpotPublication</c>) and
/// is pushed to the server via
/// <see cref="BlogApiClient.SetPublishAsync"/> on explicit
/// toggle — it is NOT included in the regular Save
/// payload, mirroring the wire contract where
/// <c>BlogPostDto</c> doesn't carry <c>Publish</c> as a
/// mutable field. Toggling is its own action.</summary>
[ObservableProperty]
public partial bool DraftIsPublished { get; set; }
[ObservableProperty] [ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; } public partial ViewModelBase? CurrentViewModel { get; set; }
@ -102,6 +114,7 @@ public partial class MainPageViewModel : ViewModelBase
WindowTitle = "PostIt"; WindowTitle = "PostIt";
DraftTitle = string.Empty; DraftTitle = string.Empty;
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false;
CurrentViewModel = this; CurrentViewModel = this;
} }
@ -131,6 +144,9 @@ public partial class MainPageViewModel : ViewModelBase
// doesn't show stale content. // doesn't show stale content.
DraftTitle = value?.Title ?? string.Empty; DraftTitle = value?.Title ?? string.Empty;
DraftArticle = value?.Article ?? string.Empty; DraftArticle = value?.Article ?? string.Empty;
// Mirror publication state too. Defaults to false on
// null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false;
UpdateCommandStates(); UpdateCommandStates();
} }
@ -241,6 +257,46 @@ public partial class MainPageViewModel : ViewModelBase
}); });
} }
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
[RelayCommand]
internal async Task TogglePublish()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
return;
}
await ExecuteAsync(async () =>
{
var desired = !DraftIsPublished;
await BlogClient.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = desired;
StatusMessage = desired
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
});
}
[RelayCommand] [RelayCommand]
internal void OpenSettings() internal void OpenSettings()
{ {

View file

@ -35,6 +35,18 @@
<Button Command="{Binding Delete}" Content="Delete" /> <Button Command="{Binding Delete}" Content="Delete" />
<Button Command="{Binding ManageAcl}" Content="ACL" /> <Button Command="{Binding ManageAcl}" Content="ACL" />
<Button Command="{Binding OpenCircles}" Content="Mes cercles" /> <Button Command="{Binding OpenCircles}" Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires
TogglePublishCommand, which pushes the
new state to /api/blog/{id}/publish.
The CheckBox is the canonical
AvaloniaXaml 'toggle' surface; binding
IsChecked TwoWay keeps the visual state
and the buffer in sync. -->
<CheckBox Content="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Command="{Binding TogglePublishCommand}"
VerticalAlignment="Center"/>
<!-- <!--
DEV ONLY: temporary shortcut to open the signature DEV ONLY: temporary shortcut to open the signature
capture page. Production entry point is a SignalR capture page. Production entry point is a SignalR

View file

@ -19,6 +19,18 @@ public class BlogPostDto : IBlogPost
public string UserModified { get; set; } public string UserModified { get; set; }
public string Title { get; set; } public string Title { get; set; }
/// <summary>
/// Whether this post is published. Derived server-side from
/// the existence of a row in <c>BlogSpotPublication</c>
/// (a row means published, no row means draft). Not stored
/// on <c>BlogPost</c> — it's a computed projection of the
/// publication table, surfaced through the wire DTO so
/// clients can render the current state without a
/// follow-up request. Toggled via
/// <c>PUT /api/BlogApi/{id}/publish</c>.
/// </summary>
public bool IsPublished { get; set; }
public bool AuthorizeCircle(long circleId) public bool AuthorizeCircle(long circleId)
{ {
throw new NotImplementedException(); throw new NotImplementedException();

View file

@ -70,4 +70,15 @@ public sealed class BlogApiClient
public Task DeletePostAsync(long id, CancellationToken ct = default) public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct); => _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);
} }

View file

@ -154,6 +154,12 @@ public sealed class BlogsWebServerFixture : WebHostFixture
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app) protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{ {
// UseDeveloperExceptionPage gives full stack traces on
// 500s during tests — much easier to debug than the
// default empty InternalServerError body. Production
// (Yavsc.Org) wires its own exception handler; this
// fixture is test-only.
app.UseDeveloperExceptionPage();
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View file

@ -0,0 +1,151 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for the publication toggle endpoint:
/// <c>PUT /api/BlogApi/{id}/publish</c> with body
/// <c>{ "publish": bool }</c>.
///
/// <para>The endpoint is the PostIt-facing way to toggle
/// whether a post is publicly readable (via
/// <c>BlogSpotPublication</c>). It does NOT change the
/// ACL — a Public post with a non-empty ACL is still
/// restricted to the ACL's circles for authenticated
/// callers; only anonymous reads open up.</para>
///
/// <para>Same fixture as <see cref="BlogApiTests"/>:
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
/// via <see cref="TestTokenIssuer"/>.</para>
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public PublishEndpointTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
// ApplicationUser has an AlternateKey on Email; the
// InMemory provider refuses to track entities whose
// alternate key is null, so we set it explicitly.
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
});
db.SaveChanges();
}
private long SeedPost(string authorId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = $"post-by-{authorId}",
Article = "test",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
}
private string PublishUrl(long id)
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/v1/blog/{id}/publish";
private string BlogsUrl
=> _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", TestTokenIssuer.Issue(subject));
return http;
}
[Fact]
public async Task PutPublish_true_returns_204_and_sets_IsPublished_in_subsequent_GET()
{
ResetDatabase();
var postId = SeedPost("alice");
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task PutPublish_false_returns_204_and_clears_IsPublished()
{
ResetDatabase();
var postId = SeedPost("alice");
using var http = NewClient("alice");
await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task PutPublish_on_unknown_post_returns_404()
{
ResetDatabase();
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true });
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
}
[Fact]
public async Task PutPublish_by_non_author_returns_challenge()
{
ResetDatabase();
var postId = SeedPost("alice");
using var http = NewClient("bob");
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
// 401 Challenge (the controller returns Challenge()
// for AuthorizationFailureException). The exact code
// is framework-dependent; what matters is "not 204".
Assert.NotEqual(HttpStatusCode.NoContent, put.StatusCode);
}
}

View file

@ -139,9 +139,56 @@ namespace Yavsc.Blogs.Controllers
return Ok(blog); return Ok(blog);
} }
/// <summary>
/// Toggle a post's publication state. <c>true</c> adds
/// a row to <c>blogSpotPublications</c> (the post
/// becomes publicly readable via
/// <c>PermissionHandler.IsPublic</c>); <c>false</c>
/// removes it.
///
/// <para>PUT (not POST) because the operation is
/// idempotent — the resulting state is determined by
/// the body, not by the request. Returns 204 No
/// Content on success, 404 when the post does not
/// exist, 403 (Challenge) when the caller is not the
/// author.</para>
/// </summary>
// PUT: api/BlogApi/5/publish
// body: { "publish": true }
[HttpPut("{id}/publish")]
public async Task<IActionResult> PutPublish(
[FromRoute] long id,
[FromBody] SetPublishBody body)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
var ok = await blogSpotService.SetPublishAsync(User, id, body.Publish);
if (!ok) return NotFound();
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
catch (AuthorizationFailureException)
{
return Challenge();
}
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing); base.Dispose(disposing);
} }
} }
/// <summary>
/// Wire body for <c>PUT /api/BlogApi/{id}/publish</c>.
/// Intentionally tiny: just the desired publication state.
/// </summary>
public sealed class SetPublishBody
{
public bool Publish { get; set; }
}
} }

View file

@ -41,31 +41,31 @@ namespace Yavsc.Models
/// User's posts /// User's posts
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[InverseProperty("Author"), JsonIgnore] [InverseProperty("Author"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual List<Blog.BlogPost>? Posts { get; set; } public virtual List<Blog.BlogPost>? Posts { get; set; }
/// <summary> /// <summary>
/// User's contact list /// User's contact list
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[InverseProperty("Owner"), JsonIgnore] [InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual List<Contact>? Book { get; set; } public virtual List<Contact>? Book { get; set; }
/// <summary> /// <summary>
/// External devices using the API /// External devices using the API
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[InverseProperty("DeviceOwner"), JsonIgnore] [InverseProperty("DeviceOwner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual List<DeviceDeclaration>? DeviceDeclaration { get; set; } public virtual List<DeviceDeclaration>? DeviceDeclaration { get; set; }
[InverseProperty("Owner"), JsonIgnore] [InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual List<ChatConnection>? Connections { get; set; } public virtual List<ChatConnection>? Connections { get; set; }
/// <summary> /// <summary>
/// User's circles /// User's circles
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[InverseProperty("Owner"), JsonIgnore] [InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual List<Circle>? Circles { get; set; } public virtual List<Circle>? Circles { get; set; }
@ -96,28 +96,28 @@ namespace Yavsc.Models
public long MaxFileSize { get; set; } = 512 * 1024 * 1024; public long MaxFileSize { get; set; } = 512 * 1024 * 1024;
[JsonIgnore] [JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[InverseProperty("Owner")] [InverseProperty("Owner")]
public virtual List<BlackListed>? BlackList { get; set; } public virtual List<BlackListed>? BlackList { get; set; }
public bool AllowMonthlyEmail { get; set; } = false; public bool AllowMonthlyEmail { get; set; } = false;
[JsonIgnore] [JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[InverseProperty("Owner")] [InverseProperty("Owner")]
public virtual List<ChatRoom>? Rooms { get; set; } public virtual List<ChatRoom>? Rooms { get; set; }
[JsonIgnore] [JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[InverseProperty("User")] [InverseProperty("User")]
public virtual List<ChatRoomAccess>? RoomAccess { get; set; } public virtual List<ChatRoomAccess>? RoomAccess { get; set; }
[JsonIgnore] [JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[InverseProperty("Member")] [InverseProperty("Member")]
public virtual List<CircleMember>? Membership { get; set; } public virtual List<CircleMember>? Membership { get; set; }
/// <summary> /// <summary>
/// User's blog comments /// User's blog comments
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[InverseProperty("Author")] [InverseProperty("Author")]
public virtual List<Blog.Comment>? BlogComments { get; set; } public virtual List<Blog.Comment>? BlogComments { get; set; }

View file

@ -95,6 +95,18 @@ namespace Yavsc.Models.Blog
[InverseProperty("Post")] [InverseProperty("Post")]
public virtual List<Comment> Comments { get; set; } public virtual List<Comment> Comments { get; set; }
/// <summary>
/// Whether this post is published. Not a column: the
/// existence of a row in <c>BlogSpotPublication</c>
/// is the source of truth. EF skips this property via
/// <c>[NotMapped]</c> so no migration is needed. The
/// service hydrates it after each fetch (single bulk
/// lookup, not N+1) and it surfaces through the wire
/// as part of the JSON-serialised <c>BlogPost</c>.
/// </summary>
[NotMapped]
public bool IsPublished { get; set; }
IApplicationUser IBlogPost.Author => Author; IApplicationUser IBlogPost.Author => Author;
} }
} }

View file

@ -115,6 +115,10 @@ public class BlogSpotService
{ {
return null; return null;
} }
// Hydrate the [NotMapped] IsPublished flag from the
// publication table so the wire JSON carries it.
blog.IsPublished = await _context.blogSpotPublications
.AnyAsync(pub => pub.BlogpostId == blogPostId);
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission()); var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
if (!auth.Succeeded) if (!auth.Succeeded)
{ {
@ -225,10 +229,31 @@ public class BlogSpotService
.Select(p => p.BlogPost).ToArray(); .Select(p => p.BlogPost).ToArray();
} }
var data = posts.OrderByDescending(p => p.DateModified) // Materialise before hydrating IsPublished: it's a
// computed [NotMapped] property that needs to be set
// on each BlogPost instance after the query runs.
var materialised = posts.ToList();
// Single bulk lookup for the IsPublished flag — avoid
// the N+1 of one AnyAsync per post. The published ids
// are loaded once and matched against the post list
// in memory.
var postIds = materialised.OfType<BlogPost>().Select(p => p.Id).ToList();
if (postIds.Count > 0)
{
var publishedIds = await _context.blogSpotPublications
.Where(pub => postIds.Contains(pub.BlogpostId))
.Select(pub => pub.BlogpostId)
.ToListAsync();
var publishedSet = publishedIds.ToHashSet();
foreach (var post in materialised.OfType<BlogPost>())
post.IsPublished = publishedSet.Contains(post.Id);
}
return materialised
.OrderByDescending(p => p.DateModified)
.Skip(skip) .Skip(skip)
.Take(take); .Take(take);
return data;
} }
public async Task Delete(ClaimsPrincipal user, long id) public async Task Delete(ClaimsPrincipal user, long id)
@ -268,4 +293,55 @@ public class BlogSpotService
.SingleOrDefaultAsync(x => x.Id == value); .SingleOrDefaultAsync(x => x.Id == value);
} }
/// <summary>
/// Toggle a post's publication state. <paramref name="publish"/>
/// true adds a row to <c>blogSpotPublications</c> (the post
/// becomes visible to anonymous callers via
/// <see cref="PermissionHandler.IsPublic"/>); false removes
/// the row if present.
///
/// <para>The post must already exist (caller must be the
/// author — this is gated by the controller's EditPermission
/// check). Returns false when the post does not exist; true
/// on a successful toggle.</para>
///
/// <para>This is the same toggle the
/// <see cref="BlogPostEditViewModel"/>-flavoured
/// <see cref="Modify(ClaimsPrincipal, BlogPostEditViewModel)"/>
/// overload performs inline; extracted here so the
/// /api/blog/{id}/publish endpoint can hit it without
/// forcing the caller to round-trip the full BlogPost in
/// the request body.</para>
/// </summary>
public async Task<bool> SetPublishAsync(ClaimsPrincipal user, long postId, bool publish)
{
var blog = await _context.BlogSpot.SingleOrDefaultAsync(b => b.Id == postId);
if (blog == null) return false;
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth);
}
var existing = await _context.blogSpotPublications.SingleOrDefaultAsync(
p => p.BlogpostId == postId);
if (publish)
{
if (existing == null)
{
_context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId });
}
}
else
{
if (existing != null)
{
_context.blogSpotPublications.Remove(existing);
}
}
await _context.SaveChangesAsync(user.GetUserId());
return true;
}
} }