yavsc/src/Yavsc.Server/Services/BlogSpotService.cs

346 lines
13 KiB
C#
Raw Normal View History

2026-06-19 21:13:17 +01:00
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
using Yavsc.Services;
using Yavsc.ViewModels.Auth;
using Microsoft.AspNetCore.Http;
2026-08-03 01:32:59 +01:00
using Yavsc.Blogspot;
2026-06-19 21:13:17 +01:00
public class BlogSpotService
{
private readonly ApplicationDbContext _context;
private readonly IAuthorizationService _authorizationService;
private readonly IFileSystemAuthManager fileSystemAuthManager;
public BlogSpotService(ApplicationDbContext context,
IAuthorizationService authorizationService,
IFileSystemAuthManager fileSystemAuthManager)
{
_authorizationService = authorizationService;
_context = context;
this.fileSystemAuthManager = fileSystemAuthManager;
}
2026-08-19 14:09:31 +01:00
public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files)
2026-06-19 21:13:17 +01:00
{
// Sauvegarder le post d'abord pour obtenir son ID
// Le créateur vient de l'authentification, donc on ne le prend pas du post
post.AuthorId = userId;
2026-06-19 21:13:17 +01:00
_context.BlogSpot.Add(post);
_context.SaveChanges(userId);
// Traiter les fichiers attachés s'il y en a
if (files != null && files.Count > 0)
{
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
if (user != null)
{
try
{
// Créer un répertoire pour les fichiers du blog
string blogFilesSubdir = $"blogs/{post.Id}";
string destDir = Path.Combine(
AbstractFileSystemHelpers.UserFilesDirName,
user.UserName,
blogFilesSubdir
);
var di = new DirectoryInfo(destDir);
if (!di.Exists) di.Create();
// Traiter chaque fichier
foreach (var formFile in files)
{
var fileInfo = user.ReceiveUserFile(destDir, formFile);
if (fileInfo != null && !fileInfo.QuotaOffense)
{
// Créer une entrée UploadedFile si nécessaire
var uploadedFile = new UploadedFile
{
Path = fileInfo.FileName,
ContentType = formFile.ContentType,
Length = formFile.Length
};
_context.UploadedFiles.Add(uploadedFile);
_context.SaveChanges(userId);
// Lier le fichier au post
var attachment = new BlogAttachedFile
{
PostId = post.Id,
FileId = uploadedFile.Id
};
_context.BlogAttachedFiles.Add(attachment);
}
}
_context.SaveChanges(userId);
}
catch (Exception ex)
{
// Logger l'erreur mais ne pas échouer la création du post
System.Diagnostics.Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
}
}
}
return post;
}
public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId)
{
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());
if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth);
}
2026-06-19 21:13:17 +01:00
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
2026-06-19 21:13:17 +01:00
return new BlogPostEditViewModel(blog, pub);
}
2026-08-19 14:09:31 +01:00
public async Task<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
2026-06-19 21:13:17 +01:00
{
2026-08-19 14:09:31 +01:00
Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot
2026-06-19 21:13:17 +01:00
.Include(p => p.Author)
.Include(p => p.Tags)
.Include(p => p.Comments)
.Include(p => p.ACL)
.SingleAsync(m => m.Id == blogPostId);
if (blog == null)
{
return null;
}
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
// 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);
2026-06-19 21:13:17 +01:00
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth);
}
foreach (var c in blog.Comments)
{
c.Author = _context.Users.First(u => u.Id == c.AuthorId);
}
return blog;
}
public async Task Modify(ClaimsPrincipal user, BlogPostEditViewModel blogEdit)
{
var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id);
Debug.Assert(blog != null);
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth);
}
blog.Article = blogEdit.Article;
blog.Title = blogEdit.Title;
blog.Photo = blogEdit.Photo;
blog.ACL = blogEdit.ACL;
// saves the change
_context.Update(blog);
var publication = await _context.blogSpotPublications.SingleOrDefaultAsync
(p => p.BlogpostId == blogEdit.Id);
if (publication != null)
{
if (!blogEdit.Publish)
{
_context.blogSpotPublications.Remove(publication);
}
}
else
{
if (blogEdit.Publish)
{
_context.blogSpotPublications.Add(
new BlogSpotPublication
{
BlogpostId = blogEdit.Id
}
);
}
}
_context.SaveChanges(user.GetUserId());
}
2026-08-19 14:09:31 +01:00
public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog)
2026-06-19 21:13:17 +01:00
{
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
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());
}
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();
long[] userCircles = await _context.Circle.Include(c => c.Members).
Where(c => c.Members.Any(m => m.MemberId == viewerId))
.Select(c => c.Id).ToArrayAsync();
posts = _context.BlogSpot
.Include(b => b.Author)
.Include(p => p.ACL)
.Include(p => p.Tags)
.Include(p => p.Comments)
.Where(p => p.ACL == null
|| p.ACL.Count == 0
|| (p.AuthorId == viewerId)
|| (userCircles != null &&
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
);
2026-06-19 21:13:17 +01:00
}
else
{
posts = _context.blogSpotPublications
.Include(p => p.BlogPost)
.Include(b => b.BlogPost.Author)
.Include(p => p.BlogPost.ACL)
.Include(p => p.BlogPost.Tags)
.Include(p => p.BlogPost.Comments)
.Where(p => p.BlogPost.ACL == null
|| p.BlogPost.ACL.Count == 0)
2026-06-19 21:13:17 +01:00
.Select(p => p.BlogPost).ToArray();
}
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
// 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.
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 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
if (postIds.Count > 0)
{
var publishedIds = await _context.blogSpotPublications
.Where(pub => postIds.Contains(pub.BlogpostId))
.Select(pub => pub.BlogpostId)
.ToListAsync();
var publishedSet = publishedIds.ToHashSet();
2026-08-19 14:09:31 +01:00
foreach (var post in materialised.OfType<Yavsc.Models.Blog.BlogPost>())
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
post.IsPublished = publishedSet.Contains(post.Id);
}
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)
{
var uid = user.GetUserId();
2026-08-19 14:09:31 +01:00
Yavsc.Models.Blog.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-19 14:09:31 +01:00
public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
2026-06-19 21:13:17 +01:00
string posterName,
string? readerId,
int pageLen = 10,
int pageNum = 0)
{
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
2026-08-19 14:09:31 +01:00
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
2026-06-19 21:13:17 +01:00
return _context.UserPosts(posterId, readerId);
}
public object? GetTitle(string title)
{
return _context.BlogSpot.Include(
b => b.Author
).Where(x => x.Title == title).OrderByDescending(
x => x.DateCreated
).ToList();
}
2026-08-19 14:09:31 +01:00
public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
2026-06-19 21:13:17 +01:00
{
return await _context.BlogSpot
.Include(b => b.Author)
.Include(b => b.ACL)
.SingleOrDefaultAsync(x => x.Id == value);
}
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. <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;
}
2026-06-19 21:13:17 +01:00
}