2026-09-06 16:11:37 +01:00
#nullable enable annotations
2026-06-19 21:13:17 +01:00
using System.Diagnostics ;
using System.Security.Claims ;
using Microsoft.AspNetCore.Authorization ;
2026-08-30 19:37:55 +01:00
using Microsoft.AspNetCore.Http ;
2026-06-19 21:13:17 +01:00
using Microsoft.EntityFrameworkCore ;
2026-09-18 13:38:42 +01:00
using Microsoft.Extensions.Logging ;
2026-09-17 22:22:18 +01:00
using Microsoft.Extensions.Options ;
using Yavsc ;
2026-08-30 19:37:55 +01:00
using Yavsc.Blogspot ;
2026-06-19 21:13:17 +01:00
using Yavsc.Models ;
2026-08-30 19:37:55 +01:00
using Yavsc.Models.Access ;
2026-06-19 21:13:17 +01:00
using Yavsc.Models.Blog ;
using Yavsc.Server.Exceptions ;
using Yavsc.Server.Helpers ;
using Yavsc.Services ;
using Yavsc.ViewModels.Auth ;
public class BlogSpotService
{
private readonly ApplicationDbContext _context ;
private readonly IAuthorizationService _authorizationService ;
private readonly IFileSystemAuthManager fileSystemAuthManager ;
2026-09-17 22:22:18 +01:00
private readonly SiteSettings siteSettings ;
2026-09-18 13:38:42 +01:00
private readonly ILogger < BlogSpotService > logger ;
2026-06-19 21:13:17 +01:00
2026-08-30 19:37:55 +01:00
public BlogSpotService (
ApplicationDbContext context ,
IAuthorizationService authorizationService ,
2026-09-17 22:22:18 +01:00
IFileSystemAuthManager fileSystemAuthManager ,
2026-09-18 13:38:42 +01:00
IOptions < SiteSettings > siteSettings ,
ILoggerFactory loggerFactory )
2026-06-19 21:13:17 +01:00
{
_authorizationService = authorizationService ;
_context = context ;
this . fileSystemAuthManager = fileSystemAuthManager ;
2026-09-17 22:22:18 +01:00
this . siteSettings = siteSettings . Value ;
2026-09-18 13:38:42 +01:00
if ( siteSettings . Value . Blog = = null )
{
throw new InvalidOperationException ( "SiteSettings.Blog is not configured." ) ;
}
this . logger = loggerFactory . CreateLogger < BlogSpotService > ( ) ;
2026-06-19 21:13:17 +01:00
}
2026-09-17 22:22:18 +01:00
public void AttachFiles ( IFormFileCollection files , string userId , long postId )
2026-06-19 21:13:17 +01:00
{
2026-08-30 19:37:55 +01:00
// Traiter les fichiers attaches s'il y en a
2026-06-19 21:13:17 +01:00
if ( files ! = null & & files . Count > 0 )
{
var user = _context . Users . FirstOrDefault ( u = > u . Id = = userId ) ;
2026-09-17 22:22:18 +01:00
if ( user = = null )
throw new InvalidOperationException ( $"Utilisateur {userId} non trouvé." ) ;
try
2026-06-19 21:13:17 +01:00
{
2026-09-17 22:22:18 +01:00
string blogFilesSubdir = $"blogs/{postId}" ;
string destDir = Path . Combine (
siteSettings . Blog ,
user . UserName ,
blogFilesSubdir ) ;
var di = new DirectoryInfo ( destDir ) ;
2026-09-18 13:38:42 +01:00
2026-09-19 21:08:10 +01:00
if ( ! di . Exists )
2026-09-18 13:38:42 +01:00
{
logger . LogInformation ( "Creating directory for blog attachments: {destDir}" , destDir ) ;
di . Create ( ) ;
}
2026-09-17 22:22:18 +01:00
foreach ( var formFile in files )
2026-06-19 21:13:17 +01:00
{
2026-09-17 22:22:18 +01:00
var fileInfo = user . ReceiveUserFile ( destDir , formFile ) ;
if ( fileInfo ! = null & & ! fileInfo . QuotaOffense )
2026-06-19 21:13:17 +01:00
{
2026-09-18 13:38:42 +01:00
logger . LogInformation ( "Attached file {fileName} to blog post {postId} for user {userId}." , fileInfo . FileName , postId , userId ) ;
2026-09-17 22:22:18 +01:00
var uploadedFile = new UploadedFile
2026-06-19 21:13:17 +01:00
{
2026-09-17 22:22:18 +01:00
Path = fileInfo . FileName ,
ContentType = formFile . ContentType ,
Length = formFile . Length
} ;
_context . UploadedFiles . Add ( uploadedFile ) ;
_context . SaveChanges ( userId ) ;
var attachment = new BlogAttachedFile
{
PostId = postId ,
FileId = uploadedFile . Id
} ;
_context . BlogAttachedFiles . Add ( attachment ) ;
2026-09-18 13:38:42 +01:00
logger . LogInformation ( "Created BlogAttachedFile entry for file {fileName} and blog post {postId}." , fileInfo . FileName , postId ) ;
}
else
{
logger . LogWarning ( "Failed to attach file {fileName} to blog post {postId} for user {userId}. Quota offense: {quotaOffense}" , formFile . FileName , postId , userId , fileInfo ? . QuotaOffense ) ;
2026-06-19 21:13:17 +01:00
}
}
2026-09-17 22:22:18 +01:00
_context . SaveChanges ( userId ) ;
}
catch ( Exception ex )
{
Debug . WriteLine ( $"Erreur lors du traitement des fichiers : {ex.Message}" ) ;
2026-09-18 13:38:42 +01:00
logger . LogError ( ex , "Error while processing attached files for blog post {postId} and user {userId}." , postId , userId ) ;
2026-06-19 21:13:17 +01:00
}
}
2026-09-17 22:22:18 +01:00
}
2026-06-19 21:13:17 +01:00
2026-09-17 22:22:18 +01:00
public BlogPost Create ( string userId , BlogPost post )
{
// Sauvegarder le post d'abord pour obtenir son ID
// Le createur vient de l'authentification, donc on ne le prend pas du post
post . AuthorId = userId ;
_context . BlogSpot . Add ( post ) ;
_context . SaveChanges ( userId ) ;
2026-06-19 21:13:17 +01:00
return post ;
}
2026-08-30 19:37:55 +01:00
2026-06-19 21:13:17 +01:00
public async Task < BlogPostEditViewModel > GetPostForEdition ( ClaimsPrincipal user , long blogPostId )
{
2026-08-30 19:37:55 +01:00
var blog = await _context . BlogSpot
. Include ( x = > x . Author )
. Include ( x = > x . ACL )
. SingleAsync ( m = > m . Id = = blogPostId ) ;
var auth = await _authorizationService . AuthorizeAsync ( user , blog , new EditPermission ( ) ) ;
2026-06-19 21:13:17 +01:00
if ( ! auth . Succeeded )
throw new AuthorizationFailureException ( auth ) ;
2026-08-30 19:37:55 +01:00
2026-09-19 21:08:10 +01:00
var pub = await _context . blogSpotPublications . AnyAsync ( x = > x . PostId = = blog . Id ) ;
2026-08-30 19:37:55 +01:00
ScrubAclForViewer ( blog , user ) ;
2026-08-02 23:02:54 +01:00
2026-06-19 21:13:17 +01:00
return new BlogPostEditViewModel ( blog , pub ) ;
}
2026-08-30 19:37:55 +01:00
public async Task < BlogPost > Details ( ClaimsPrincipal user , long blogPostId )
2026-06-19 21:13:17 +01:00
{
2026-08-30 19:37:55 +01:00
BlogPost blog = await _context . BlogSpot
. Include ( p = > p . Author )
. Include ( p = > p . Tags )
. Include ( p = > p . Comments )
. Include ( p = > p . ACL )
. SingleAsync ( m = > m . Id = = blogPostId ) ;
2026-06-19 21:13:17 +01:00
if ( blog = = null )
return null ;
2026-08-30 19:37:55 +01:00
// Hydrate le flag [NotMapped] depuis la table de publication.
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
blog . IsPublished = await _context . blogSpotPublications
2026-09-19 21:08:10 +01:00
. AnyAsync ( pub = > pub . PostId = = blogPostId ) ;
2026-08-30 19:37:55 +01:00
2026-06-19 21:13:17 +01:00
var auth = await _authorizationService . AuthorizeAsync ( user , blog , new ReadPermission ( ) ) ;
if ( ! auth . Succeeded )
throw new AuthorizationFailureException ( auth ) ;
2026-08-30 19:37:55 +01:00
ScrubAclForViewer ( blog , user ) ;
2026-06-19 21:13:17 +01:00
foreach ( var c in blog . Comments )
c . Author = _context . Users . First ( u = > u . Id = = c . AuthorId ) ;
2026-08-30 19:37:55 +01:00
2026-06-19 21:13:17 +01:00
return blog ;
}
public async Task Modify ( ClaimsPrincipal user , BlogPostEditViewModel blogEdit )
{
var blog = _context . BlogSpot . SingleOrDefault ( b = > b . Id = = blogEdit . Id ) ;
Debug . Assert ( blog ! = null ) ;
2026-08-30 19:37:55 +01:00
2026-06-19 21:13:17 +01:00
var auth = await _authorizationService . AuthorizeAsync ( user , blog , new EditPermission ( ) ) ;
if ( ! auth . Succeeded )
throw new AuthorizationFailureException ( auth ) ;
2026-08-30 19:37:55 +01:00
2026-06-19 21:13:17 +01:00
blog . Article = blogEdit . Article ;
blog . Title = blogEdit . Title ;
blog . Photo = blogEdit . Photo ;
blog . ACL = blogEdit . ACL ;
_context . Update ( blog ) ;
2026-08-30 19:37:55 +01:00
var publication = await _context . blogSpotPublications
2026-09-19 21:08:10 +01:00
. SingleOrDefaultAsync ( p = > p . PostId = = blogEdit . Id ) ;
2026-08-30 19:37:55 +01:00
2026-06-19 21:13:17 +01:00
if ( publication ! = null )
{
if ( ! blogEdit . Publish )
_context . blogSpotPublications . Remove ( publication ) ;
}
2026-08-30 19:37:55 +01:00
else if ( blogEdit . Publish )
2026-06-19 21:13:17 +01:00
{
2026-09-19 21:08:10 +01:00
_context . blogSpotPublications . Add ( new BlogSpotPublication { PostId = blogEdit . Id } ) ;
2026-06-19 21:13:17 +01:00
}
2026-08-30 19:37:55 +01:00
2026-06-19 21:13:17 +01:00
_context . SaveChanges ( user . GetUserId ( ) ) ;
}
2026-08-30 19:37:55 +01:00
public async Task Modify ( ClaimsPrincipal user , BlogPost blog )
2026-06-19 21:13:17 +01:00
{
2026-08-30 19:37:55 +01:00
var existing = await _context . BlogSpot
. Include ( b = > b . ACL )
. SingleOrDefaultAsync ( b = > b . Id = = blog . Id ) ;
2026-06-19 21:13:17 +01:00
if ( existing = = null )
throw new InvalidOperationException ( $"Blog post {blog.Id} not found." ) ;
var auth = await _authorizationService . AuthorizeAsync ( user , existing , new EditPermission ( ) ) ;
if ( ! auth . Succeeded )
throw new AuthorizationFailureException ( auth ) ;
existing . Title = blog . Title ;
existing . Article = blog . Article ;
existing . Photo = blog . Photo ;
existing . ACL = blog . ACL ;
_context . Update ( existing ) ;
_context . SaveChanges ( user . GetUserId ( ) ) ;
}
2026-09-07 15:41:33 +01:00
2026-06-19 21:13:17 +01:00
public async Task < IEnumerable < IBlogPost > > Index ( ClaimsPrincipal user , string id , int skip = 0 , int take = 25 )
{
IEnumerable < IBlogPost > posts ;
if ( user . Identity . IsAuthenticated )
{
string viewerId = user . GetUserId ( ) ;
2026-08-30 19:37:55 +01:00
long [ ] userCircles = await _context . Circle . Include ( c = > c . Members )
. Where ( c = > c . Members . Any ( m = > m . MemberId = = viewerId ) )
. Select ( c = > c . Id )
. ToArrayAsync ( ) ;
2026-06-19 21:13:17 +01:00
posts = _context . BlogSpot
. Include ( b = > b . Author )
. Include ( p = > p . ACL )
. Include ( p = > p . Tags )
. Include ( p = > p . Comments )
2026-09-19 21:08:10 +01:00
. Include ( p = > p . Publication )
. Where ( p = > p . Publication ! = null
2026-08-30 19:37:55 +01:00
| | ( userCircles ! = null & & p . ACL . Any ( a = > userCircles . Contains ( a . CircleId ) ) ) ) ;
2026-06-19 21:13:17 +01:00
}
else
{
posts = _context . blogSpotPublications
2026-09-19 21:08:10 +01:00
. Include ( p = > p . Post )
. Include ( b = > b . Post . Author )
. Include ( p = > p . Post . ACL )
. Include ( p = > p . Post . Tags )
. Include ( p = > p . Post . Comments )
. Select ( p = > p . Post )
2026-08-30 19:37:55 +01:00
. ToArray ( ) ;
2026-06-19 21:13:17 +01: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
var materialised = posts . ToList ( ) ;
2026-08-19 14:09:31 +01:00
var postIds = materialised . Select ( p = > p . Id ) . ToList ( ) ;
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
if ( postIds . Count > 0 )
{
var publishedIds = await _context . blogSpotPublications
2026-09-19 21:08:10 +01:00
. Where ( pub = > postIds . Contains ( pub . PostId ) )
. Select ( pub = > pub . PostId )
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
. ToListAsync ( ) ;
2026-08-30 19:37:55 +01: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
var publishedSet = publishedIds . ToHashSet ( ) ;
2026-08-30 19:37:55 +01:00
foreach ( var post in materialised . OfType < BlogPost > ( ) )
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
post . IsPublished = publishedSet . Contains ( post . Id ) ;
}
2026-08-30 19:37:55 +01:00
foreach ( var post in materialised . OfType < BlogPost > ( ) )
ScrubAclForViewer ( post , user ) ;
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
return materialised
. OrderByDescending ( p = > p . DateModified )
2026-06-19 21:13:17 +01:00
. Skip ( skip )
. Take ( take ) ;
}
public async Task Delete ( ClaimsPrincipal user , long id )
{
2026-08-30 19:37:55 +01:00
BlogPost blog = _context . BlogSpot . Single ( m = > m . Id = = id ) ;
2026-06-19 21:13:17 +01:00
_context . BlogSpot . Remove ( blog ) ;
_context . SaveChanges ( user . GetUserId ( ) ) ;
}
2026-08-30 19:37:55 +01:00
public async Task < IEnumerable < BlogPost > > UserPosts ( string posterName , string? readerId , int pageLen = 10 , int pageNum = 0 )
2026-06-19 21:13:17 +01:00
{
2026-08-30 19:37:55 +01:00
string? posterId = ( await _context . Users . SingleOrDefaultAsync ( u = > u . UserName = = posterName ) ) ? . Id ;
if ( posterId = = null ) return Array . Empty < BlogPost > ( ) ;
var posts = _context . UserPosts ( posterId , readerId ) . ToList ( ) ;
var isOwnerReader = string . Equals ( readerId , posterId , StringComparison . Ordinal ) ;
foreach ( var post in posts )
{
if ( ! isOwnerReader )
post . ACL = new List < CircleAuthorizationToBlogPost > ( ) ;
}
return posts ;
2026-06-19 21:13:17 +01:00
}
public object? GetTitle ( string title )
{
2026-08-30 19:37:55 +01:00
return _context . BlogSpot
. Include ( b = > b . Author )
. Where ( x = > x . Title = = title )
. OrderByDescending ( x = > x . DateCreated )
. ToList ( ) ;
2026-06-19 21:13:17 +01:00
}
2026-08-30 19:37:55 +01:00
public async Task < BlogPost ? > GetBlogPostAsync ( long value )
2026-06-19 21:13:17 +01:00
{
return await _context . BlogSpot
2026-08-30 19:37:55 +01:00
. Include ( b = > b . Author )
. Include ( b = > b . ACL )
. SingleOrDefaultAsync ( x = > x . Id = = value ) ;
2026-06-19 21:13:17 +01: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
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 ) ;
2026-09-19 21:08:10 +01:00
var existing = await _context . blogSpotPublications . SingleOrDefaultAsync ( p = > p . PostId = = postId ) ;
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
if ( publish )
{
if ( existing = = null )
2026-09-19 21:08:10 +01:00
_context . blogSpotPublications . Add ( new BlogSpotPublication { PostId = postId } ) ;
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
}
else
{
if ( existing ! = null )
_context . blogSpotPublications . Remove ( existing ) ;
}
2026-08-30 19:37:55 +01: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
await _context . SaveChangesAsync ( user . GetUserId ( ) ) ;
return true ;
}
2026-08-30 19:37:55 +01:00
private static void ScrubAclForViewer ( BlogPost post , ClaimsPrincipal ? user )
{
if ( ! IsOwner ( post , user ) )
post . ACL = new List < CircleAuthorizationToBlogPost > ( ) ;
}
private static bool IsOwner ( BlogPost post , ClaimsPrincipal ? user )
{
if ( user ? . Identity ? . IsAuthenticated ! = true ) return false ;
return string . Equals ( user . GetUserId ( ) , post . AuthorId , StringComparison . Ordinal ) ;
}
2026-06-19 21:13:17 +01:00
}