2026-06-06 21:30:41 +01:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
2026-09-07 15:41:33 +01:00
|
|
|
using Microsoft.AspNetCore.Http;
|
2023-03-19 17:57:55 +00:00
|
|
|
using Microsoft.AspNetCore.Mvc;
|
2026-09-07 15:41:33 +01:00
|
|
|
using System.Text.Json;
|
2026-08-03 01:32:59 +01:00
|
|
|
using Yavsc.Blogspot;
|
2026-06-10 00:23:17 +01:00
|
|
|
using Yavsc.Server.Exceptions;
|
2025-07-15 19:43:41 +01:00
|
|
|
using Yavsc.Server.Helpers;
|
2026-08-20 20:50:52 +01:00
|
|
|
using static Yavsc.Constants;
|
2019-01-01 16:28:47 +00:00
|
|
|
|
2026-06-10 16:59:23 +01:00
|
|
|
namespace Yavsc.Blogs.Controllers
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
2026-06-06 21:30:41 +01:00
|
|
|
[Authorize("BlogScope")]
|
2019-01-01 16:28:47 +00:00
|
|
|
[Produces("application/json")]
|
2026-08-28 20:10:32 +01:00
|
|
|
[Route(APIPrefix + "/" + BlogSpotPath)]
|
2019-01-01 16:28:47 +00:00
|
|
|
public class BlogApiController : Controller
|
|
|
|
|
{
|
2026-06-10 00:23:17 +01:00
|
|
|
private readonly BlogSpotService blogSpotService;
|
2026-09-18 13:38:42 +01:00
|
|
|
private readonly ILogger<BlogApiController> logger;
|
2019-01-01 16:28:47 +00:00
|
|
|
|
2026-09-18 13:38:42 +01:00
|
|
|
public BlogApiController(
|
|
|
|
|
BlogSpotService blogSpotService,
|
|
|
|
|
ILoggerFactory loggerFactory)
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
2026-06-10 00:23:17 +01:00
|
|
|
this.blogSpotService = blogSpotService;
|
2026-09-18 13:38:42 +01:00
|
|
|
this.logger = loggerFactory.CreateLogger<BlogApiController>();
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 20:10:32 +01:00
|
|
|
// GET: api/v1/blogspot
|
2019-01-01 16:28:47 +00:00
|
|
|
[HttpGet]
|
2026-08-05 21:11:22 +01:00
|
|
|
public async Task<IEnumerable<IBlogPost>> GetBlogspot(int start = 0, int take = 25)
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
2026-08-05 21:11:22 +01:00
|
|
|
return await blogSpotService.Index(User, null, start, take);
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 20:10:32 +01:00
|
|
|
// GET: api/v1/blogspot/5
|
2019-01-01 16:28:47 +00:00
|
|
|
[HttpGet("{id}", Name = "GetBlog")]
|
2026-06-10 00:23:17 +01:00
|
|
|
public async Task<IActionResult> GetBlog([FromRoute] long id)
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
|
|
|
|
if (!ModelState.IsValid)
|
|
|
|
|
{
|
2023-03-19 17:57:55 +00:00
|
|
|
return BadRequest(ModelState);
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 00:23:17 +01:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var blog = await blogSpotService.Details(User, id);
|
|
|
|
|
if (blog == null)
|
|
|
|
|
{
|
|
|
|
|
return NotFound();
|
|
|
|
|
}
|
2019-01-01 16:28:47 +00:00
|
|
|
|
2026-08-28 20:10:32 +01:00
|
|
|
return Ok(blog.GetPayload());
|
2026-06-10 00:23:17 +01:00
|
|
|
}
|
|
|
|
|
catch (AuthorizationFailureException)
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
2026-06-10 00:23:17 +01:00
|
|
|
return Challenge();
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 20:10:32 +01:00
|
|
|
// PUT: api/v1/blogspot/5
|
2019-01-01 16:28:47 +00:00
|
|
|
[HttpPut("{id}")]
|
2026-09-07 15:41:33 +01:00
|
|
|
public async Task<IActionResult> PutBlog(long id)
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
2026-09-17 22:22:18 +01:00
|
|
|
var blog = await ReadBlogRequestAsync();
|
2026-09-07 15:41:33 +01:00
|
|
|
if (blog is null)
|
|
|
|
|
{
|
2026-09-18 13:38:42 +01:00
|
|
|
logger.LogWarning("PUT /api/v1/blogspot/{id} failed to read blog payload from request body or multipart form.", id);
|
2026-09-07 15:41:33 +01:00
|
|
|
return BadRequest(ModelState);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 22:22:18 +01:00
|
|
|
// Without [FromBody], the data-annotations attributes
|
|
|
|
|
// ([Required] on Title, etc.) are no longer evaluated by
|
|
|
|
|
// the model binder — the body is read manually. Run
|
|
|
|
|
// validation explicitly so an empty Title still yields
|
|
|
|
|
// 400 (and not a 500 from the DB NOT NULL constraint).
|
|
|
|
|
var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(blog);
|
|
|
|
|
var validationResults = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
|
|
|
|
|
if (!System.ComponentModel.DataAnnotations.Validator.TryValidateObject(
|
|
|
|
|
blog, validationContext, validationResults, validateAllProperties: true))
|
|
|
|
|
{
|
|
|
|
|
foreach (var result in validationResults)
|
|
|
|
|
{
|
|
|
|
|
ModelState.AddModelError(
|
|
|
|
|
result.MemberNames.FirstOrDefault() ?? string.Empty,
|
|
|
|
|
result.ErrorMessage ?? "Invalid value.");
|
|
|
|
|
}
|
2026-09-18 13:38:42 +01:00
|
|
|
logger.LogWarning("PUT /api/v1/blogspot/{id} failed validation: {errors}", id, ModelState);
|
2026-09-17 22:22:18 +01:00
|
|
|
return BadRequest(ModelState);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 19:16:41 +01:00
|
|
|
// These properties are server-managed or optional graph members and
|
|
|
|
|
// should not block JSON payloads coming from API clients.
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.Tags));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.Comments));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified));
|
|
|
|
|
|
2019-01-01 16:28:47 +00:00
|
|
|
if (!ModelState.IsValid)
|
|
|
|
|
{
|
2023-03-19 17:57:55 +00:00
|
|
|
return BadRequest(ModelState);
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (id != blog.Id)
|
|
|
|
|
{
|
2023-03-19 17:57:55 +00:00
|
|
|
return BadRequest();
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-07 15:41:33 +01:00
|
|
|
var files = Request.HasFormContentType
|
|
|
|
|
? Request.Form.Files
|
|
|
|
|
: (IFormFileCollection)new FormFileCollection();
|
|
|
|
|
|
2026-06-10 00:23:17 +01:00
|
|
|
var existing = await blogSpotService.GetBlogPostAsync(id);
|
|
|
|
|
if (existing == null)
|
|
|
|
|
{
|
2026-09-18 13:38:42 +01:00
|
|
|
logger.LogWarning("PUT /api/v1/blogspot/{id} failed: blog post not found.", id);
|
2026-06-10 00:23:17 +01:00
|
|
|
return NotFound();
|
|
|
|
|
}
|
2019-01-01 16:28:47 +00:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-09-17 22:22:18 +01:00
|
|
|
await blogSpotService.Modify(User, blog);
|
2026-09-18 13:38:42 +01:00
|
|
|
logger.LogInformation("PUT /api/v1/blogspot/{id} modified successfully.", id);
|
|
|
|
|
if (files.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
logger.LogInformation("PUT /api/v1/blogspot/{id} attaching {fileCount} files.", id, files.Count);
|
|
|
|
|
blogSpotService.AttachFiles(files, User.GetUserId(), id);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
logger.LogInformation("PUT /api/v1/blogspot/{id} no files to attach.", id);
|
|
|
|
|
}
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
2026-06-10 00:23:17 +01:00
|
|
|
catch (AuthorizationFailureException)
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
2026-09-18 13:38:42 +01:00
|
|
|
logger.LogWarning("PUT /api/v1/blogspot/{id} failed: authorization failure.", id);
|
2026-06-10 00:23:17 +01:00
|
|
|
return Challenge();
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2023-03-19 17:57:55 +00:00
|
|
|
return new StatusCodeResult(StatusCodes.Status204NoContent);
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 20:10:32 +01:00
|
|
|
// POST: api/v1/blogspot
|
2019-01-01 16:28:47 +00:00
|
|
|
[HttpPost]
|
2026-09-17 22:22:18 +01:00
|
|
|
public async Task<IActionResult> PostBlog()
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
2026-09-17 22:22:18 +01:00
|
|
|
var blog = await ReadBlogRequestAsync();
|
|
|
|
|
if (blog is null)
|
|
|
|
|
{
|
|
|
|
|
return BadRequest(ModelState);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 19:16:41 +01:00
|
|
|
// These properties are server-managed or optional graph members and
|
|
|
|
|
// should not block JSON payloads coming from API clients.
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.Tags));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.Comments));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated));
|
|
|
|
|
ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified));
|
|
|
|
|
|
2026-09-17 22:22:18 +01:00
|
|
|
// Without [FromBody], the data-annotations attributes
|
|
|
|
|
// ([Required] on Title, etc.) are no longer evaluated by
|
|
|
|
|
// the model binder — the body is read manually. Run
|
|
|
|
|
// validation explicitly so an empty Title still yields
|
|
|
|
|
// 400 (and not a 500 from the DB NOT NULL constraint).
|
|
|
|
|
var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(blog);
|
|
|
|
|
var validationResults = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
|
|
|
|
|
if (!System.ComponentModel.DataAnnotations.Validator.TryValidateObject(
|
|
|
|
|
blog, validationContext, validationResults, validateAllProperties: true))
|
|
|
|
|
{
|
|
|
|
|
foreach (var result in validationResults)
|
|
|
|
|
{
|
|
|
|
|
ModelState.AddModelError(
|
|
|
|
|
result.MemberNames.FirstOrDefault() ?? string.Empty,
|
|
|
|
|
result.ErrorMessage ?? "Invalid value.");
|
|
|
|
|
}
|
|
|
|
|
return BadRequest(ModelState);
|
|
|
|
|
}
|
|
|
|
|
|
2019-01-01 16:28:47 +00:00
|
|
|
if (!ModelState.IsValid)
|
|
|
|
|
{
|
2023-03-19 17:57:55 +00:00
|
|
|
return BadRequest(ModelState);
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 22:03:41 +01:00
|
|
|
// Two valid use cases for this endpoint:
|
|
|
|
|
// 1. JSON body only (no files) — PostIt path.
|
|
|
|
|
// 2. multipart/form-data with a 'blog' field + 0..N
|
2026-09-17 22:22:18 +01:00
|
|
|
// files — PostIt "create with attachments" path.
|
2026-07-06 22:03:41 +01:00
|
|
|
//
|
|
|
|
|
// Branch on HasFormContentType: pass the form files when
|
2026-09-17 22:22:18 +01:00
|
|
|
// present, pass an empty collection otherwise. Reading
|
|
|
|
|
// Request.Form.Files on a plain JSON request throws
|
|
|
|
|
// "This request does not have a Content-Type header…",
|
|
|
|
|
// and AttachFiles short-circuits on an empty collection.
|
2026-07-06 22:03:41 +01:00
|
|
|
var files = Request.HasFormContentType
|
|
|
|
|
? Request.Form.Files
|
|
|
|
|
: (IFormFileCollection)new FormFileCollection();
|
2026-08-02 23:02:54 +01:00
|
|
|
var uid = User.GetUserId();
|
2026-09-17 22:22:18 +01:00
|
|
|
var post = blogSpotService.Create(uid, blog);
|
|
|
|
|
blogSpotService.AttachFiles(files, uid, post.Id);
|
2026-08-28 20:10:32 +01:00
|
|
|
return CreatedAtRoute("GetBlog", new { id = post.Id },
|
|
|
|
|
post.GetPayload());
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DELETE: api/BlogApi/5
|
|
|
|
|
[HttpDelete("{id}")]
|
2026-06-10 00:23:17 +01:00
|
|
|
public async Task<IActionResult> DeleteBlog(long id)
|
2019-01-01 16:28:47 +00:00
|
|
|
{
|
|
|
|
|
if (!ModelState.IsValid)
|
|
|
|
|
{
|
2023-03-19 17:57:55 +00:00
|
|
|
return BadRequest(ModelState);
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 00:23:17 +01:00
|
|
|
var blog = await blogSpotService.GetBlogPostAsync(id);
|
2019-01-01 16:28:47 +00:00
|
|
|
if (blog == null)
|
|
|
|
|
{
|
2023-03-19 17:57:55 +00:00
|
|
|
return NotFound();
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 00:23:17 +01:00
|
|
|
await blogSpotService.Delete(User, id);
|
2026-08-28 20:10:32 +01:00
|
|
|
return Ok(blog.GetPayload());
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
|
|
|
|
|
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 44dcbdd8,
reverted in 252327d8) 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
|
|
|
/// <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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-01-01 16:28:47 +00:00
|
|
|
protected override void Dispose(bool disposing)
|
|
|
|
|
{
|
|
|
|
|
base.Dispose(disposing);
|
|
|
|
|
}
|
2026-09-07 15:41:33 +01:00
|
|
|
|
2026-09-17 22:22:18 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Read a <see cref="Models.Blog.BlogPost"/> from either a
|
|
|
|
|
/// JSON body or a multipart/form-data request carrying the
|
|
|
|
|
/// payload in a <c>blog</c> form field. Shared by
|
|
|
|
|
/// <see cref="PostBlog"/> and <see cref="PutBlog"/>: both
|
|
|
|
|
/// accept the two shapes so PostIt can attach files on
|
|
|
|
|
/// create as well as on update. Returns <c>null</c> (with
|
|
|
|
|
/// the reason recorded in <c>ModelState</c>) when the
|
|
|
|
|
/// multipart form has no usable <c>blog</c> field.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task<Models.Blog.BlogPost?> ReadBlogRequestAsync()
|
2026-09-07 15:41:33 +01:00
|
|
|
{
|
|
|
|
|
if (!Request.HasFormContentType)
|
|
|
|
|
{
|
|
|
|
|
return await Request.ReadFromJsonAsync<Models.Blog.BlogPost>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var raw = Request.Form["blog"].ToString();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
|
|
|
{
|
|
|
|
|
ModelState.AddModelError("blog", "A blog payload is required in the multipart form field 'blog'.");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return JsonSerializer.Deserialize<Models.Blog.BlogPost>(raw, new JsonSerializerOptions
|
|
|
|
|
{
|
|
|
|
|
PropertyNameCaseInsensitive = true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
catch (JsonException ex)
|
|
|
|
|
{
|
|
|
|
|
ModelState.AddModelError("blog", $"Invalid blog JSON payload: {ex.Message}");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-01-01 16:28:47 +00:00
|
|
|
}
|
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 44dcbdd8,
reverted in 252327d8) 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
|
|
|
|
|
|
|
|
/// <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; }
|
|
|
|
|
}
|
2019-05-18 09:42:50 +01:00
|
|
|
}
|