yavsc/src/Yavsc.Blogs/Controllers/BlogApiController.cs

194 lines
6.1 KiB
C#
Raw Normal View History

2026-06-06 21:30:41 +01:00
using Microsoft.AspNetCore.Authorization;
2023-03-19 17:57:55 +00:00
using Microsoft.AspNetCore.Mvc;
2026-08-03 01:32:59 +01:00
using Yavsc.Blogspot;
2019-01-01 16:28:47 +00:00
using Yavsc.Models.Blog;
2026-06-10 00:23:17 +01:00
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
2026-06-20 18:43:01 +01:00
using static Yavsc.Blogs.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-06-20 18:43:01 +01:00
[Route(APIPrefix + "/blog")]
2019-01-01 16:28:47 +00:00
public class BlogApiController : Controller
{
2026-06-10 00:23:17 +01:00
private readonly BlogSpotService blogSpotService;
2019-01-01 16:28:47 +00:00
2026-06-10 00:23:17 +01:00
public BlogApiController(BlogSpotService blogSpotService)
2019-01-01 16:28:47 +00:00
{
2026-06-10 00:23:17 +01:00
this.blogSpotService = blogSpotService;
2019-01-01 16:28:47 +00:00
}
// GET: api/BlogApi
[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
}
// GET: api/BlogApi/5
[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-06-10 00:23:17 +01:00
return Ok(blog);
}
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
}
}
// PUT: api/BlogApi/5
[HttpPut("{id}")]
2026-08-19 14:09:31 +01:00
public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
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-06-10 00:23:17 +01:00
var existing = await blogSpotService.GetBlogPostAsync(id);
if (existing == null)
{
return NotFound();
}
2019-01-01 16:28:47 +00:00
try
{
2026-06-10 00:23:17 +01:00
await blogSpotService.Modify(User, blog);
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-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-07-12 01:17:52 +01:00
// POST: api/v1/blog
2019-01-01 16:28:47 +00:00
[HttpPost]
public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
{
if (!ModelState.IsValid)
{
2023-03-19 17:57:55 +00:00
return BadRequest(ModelState);
2019-01-01 16:28:47 +00:00
}
// The BlogSpotService.Create() signature requires an
// IFormFileCollection for file uploads. Reading
// Request.Form.Files when the request is a plain JSON
// body (e.g. from PostIt) throws
// "This request does not have a Content-Type header.
// Forms are available from requests with bodies like
// POSTs and a form Content-Type of either
// application/x-www-form-urlencoded or
// multipart/form-data."
//
// 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
// files — future browser / server-rendered path.
//
// Branch on HasFormContentType: pass the form files when
// present, pass an empty collection otherwise. The
// FileSystem branch in BlogSpotService.Create then
// short-circuits to "no files to handle".
var files = Request.HasFormContentType
? Request.Form.Files
: (IFormFileCollection)new FormFileCollection();
var uid = User.GetUserId();
var post = blogSpotService.Create(uid, blog, files);
2026-06-10 00:23:17 +01:00
return CreatedAtRoute("GetBlog", new { id = post.Id }, post);
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);
2019-01-01 16:28:47 +00:00
return Ok(blog);
}
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
/// <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);
}
}
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
/// <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
}