release/1.0.7 #34
10 changed files with 395 additions and 12 deletions
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit33ecfa7e, reverted in42625f5d) 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).
commit
3fb5f40acb
|
|
@ -35,6 +35,18 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string DraftArticle { get; set; }
|
public partial string DraftArticle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Editor buffer for the post's publication state.
|
||||||
|
/// Reflects the server-side <c>IsPublished</c> flag (the
|
||||||
|
/// existence of a row in <c>BlogSpotPublication</c>) and
|
||||||
|
/// is pushed to the server via
|
||||||
|
/// <see cref="BlogApiClient.SetPublishAsync"/> on explicit
|
||||||
|
/// toggle — it is NOT included in the regular Save
|
||||||
|
/// payload, mirroring the wire contract where
|
||||||
|
/// <c>BlogPostDto</c> doesn't carry <c>Publish</c> as a
|
||||||
|
/// mutable field. Toggling is its own action.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool DraftIsPublished { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||||
|
|
||||||
|
|
@ -102,6 +114,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
WindowTitle = "PostIt";
|
WindowTitle = "PostIt";
|
||||||
DraftTitle = string.Empty;
|
DraftTitle = string.Empty;
|
||||||
DraftArticle = string.Empty;
|
DraftArticle = string.Empty;
|
||||||
|
DraftIsPublished = false;
|
||||||
CurrentViewModel = this;
|
CurrentViewModel = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +144,9 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
// doesn't show stale content.
|
// doesn't show stale content.
|
||||||
DraftTitle = value?.Title ?? string.Empty;
|
DraftTitle = value?.Title ?? string.Empty;
|
||||||
DraftArticle = value?.Article ?? string.Empty;
|
DraftArticle = value?.Article ?? string.Empty;
|
||||||
|
// Mirror publication state too. Defaults to false on
|
||||||
|
// null selection so a fresh draft starts unpublished.
|
||||||
|
DraftIsPublished = value?.IsPublished ?? false;
|
||||||
UpdateCommandStates();
|
UpdateCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +257,46 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle the publication state of the currently selected
|
||||||
|
/// post. Pushes the new state to
|
||||||
|
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
|
||||||
|
/// locally in <see cref="DraftIsPublished"/> + the
|
||||||
|
/// selected post so the UI updates without a full
|
||||||
|
/// refresh.
|
||||||
|
///
|
||||||
|
/// <para>The toggle is its own action — separate from Save
|
||||||
|
/// — because <c>Publish</c> is not part of the
|
||||||
|
/// <c>BlogPostDto</c> payload. Bundling it into Save
|
||||||
|
/// would require a wire-shape change and a second server
|
||||||
|
/// overload; the dedicated endpoint keeps the wire
|
||||||
|
/// contract clean.</para>
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task TogglePublish()
|
||||||
|
{
|
||||||
|
if (SelectedPost is null || SelectedPost.Id == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
var desired = !DraftIsPublished;
|
||||||
|
await BlogClient.SetPublishAsync(SelectedPost.Id, desired);
|
||||||
|
DraftIsPublished = desired;
|
||||||
|
// Mirror into the selected post so a subsequent
|
||||||
|
// RefreshPostsAsync() doesn't blow away the
|
||||||
|
// locally flipped state until the round-trip
|
||||||
|
// re-hydrates it.
|
||||||
|
SelectedPost.IsPublished = desired;
|
||||||
|
StatusMessage = desired
|
||||||
|
? $"Billet {SelectedPost.Id} publié."
|
||||||
|
: $"Billet {SelectedPost.Id} remis en brouillon.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
internal void OpenSettings()
|
internal void OpenSettings()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@
|
||||||
<Button Command="{Binding Delete}" Content="Delete" />
|
<Button Command="{Binding Delete}" Content="Delete" />
|
||||||
<Button Command="{Binding ManageAcl}" Content="ACL" />
|
<Button Command="{Binding ManageAcl}" Content="ACL" />
|
||||||
<Button Command="{Binding OpenCircles}" Content="Mes cercles" />
|
<Button Command="{Binding OpenCircles}" Content="Mes cercles" />
|
||||||
|
<!-- Publication toggle: a CheckBox wired to
|
||||||
|
DraftIsPublished. Clicking it fires
|
||||||
|
TogglePublishCommand, which pushes the
|
||||||
|
new state to /api/blog/{id}/publish.
|
||||||
|
The CheckBox is the canonical
|
||||||
|
AvaloniaXaml 'toggle' surface; binding
|
||||||
|
IsChecked TwoWay keeps the visual state
|
||||||
|
and the buffer in sync. -->
|
||||||
|
<CheckBox Content="Publié"
|
||||||
|
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
||||||
|
Command="{Binding TogglePublishCommand}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
<!--
|
<!--
|
||||||
DEV ONLY: temporary shortcut to open the signature
|
DEV ONLY: temporary shortcut to open the signature
|
||||||
capture page. Production entry point is a SignalR
|
capture page. Production entry point is a SignalR
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ public class BlogPostDto : IBlogPost
|
||||||
public string UserModified { get; set; }
|
public string UserModified { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this post is published. Derived server-side from
|
||||||
|
/// the existence of a row in <c>BlogSpotPublication</c>
|
||||||
|
/// (a row means published, no row means draft). Not stored
|
||||||
|
/// on <c>BlogPost</c> — it's a computed projection of the
|
||||||
|
/// publication table, surfaced through the wire DTO so
|
||||||
|
/// clients can render the current state without a
|
||||||
|
/// follow-up request. Toggled via
|
||||||
|
/// <c>PUT /api/BlogApi/{id}/publish</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
|
||||||
public bool AuthorizeCircle(long circleId)
|
public bool AuthorizeCircle(long circleId)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
|
|
|
||||||
|
|
@ -70,4 +70,15 @@ public sealed class BlogApiClient
|
||||||
|
|
||||||
public Task DeletePostAsync(long id, CancellationToken ct = default)
|
public Task DeletePostAsync(long id, CancellationToken ct = default)
|
||||||
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
|
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set a post's publication state. <c>true</c> publishes
|
||||||
|
/// it (visible to anonymous readers via
|
||||||
|
/// <c>PermissionHandler.IsPublic</c>); <c>false</c> takes
|
||||||
|
/// it back to draft. Idempotent: the resulting state
|
||||||
|
/// matches the call, regardless of the previous state.
|
||||||
|
/// </summary>
|
||||||
|
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
|
||||||
|
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
|
||||||
|
body: new { publish }, ct: ct);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,12 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
||||||
|
|
||||||
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
|
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
|
||||||
{
|
{
|
||||||
|
// UseDeveloperExceptionPage gives full stack traces on
|
||||||
|
// 500s during tests — much easier to debug than the
|
||||||
|
// default empty InternalServerError body. Production
|
||||||
|
// (Yavsc.Org) wires its own exception handler; this
|
||||||
|
// fixture is test-only.
|
||||||
|
app.UseDeveloperExceptionPage();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
|
||||||
151
src/Yavsc.Blogs.Tests/PublishEndpointTests.cs
Normal file
151
src/Yavsc.Blogs.Tests/PublishEndpointTests.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Behavioural tests for the publication toggle endpoint:
|
||||||
|
/// <c>PUT /api/BlogApi/{id}/publish</c> with body
|
||||||
|
/// <c>{ "publish": bool }</c>.
|
||||||
|
///
|
||||||
|
/// <para>The endpoint is the PostIt-facing way to toggle
|
||||||
|
/// whether a post is publicly readable (via
|
||||||
|
/// <c>BlogSpotPublication</c>). It does NOT change the
|
||||||
|
/// ACL — a Public post with a non-empty ACL is still
|
||||||
|
/// restricted to the ACL's circles for authenticated
|
||||||
|
/// callers; only anonymous reads open up.</para>
|
||||||
|
///
|
||||||
|
/// <para>Same fixture as <see cref="BlogApiTests"/>:
|
||||||
|
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
|
||||||
|
/// via <see cref="TestTokenIssuer"/>.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("JwtClaimMapping")]
|
||||||
|
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly BlogsWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public PublishEndpointTests(BlogsWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetDatabase()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
db.Database.EnsureDeleted();
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
// ApplicationUser has an AlternateKey on Email; the
|
||||||
|
// InMemory provider refuses to track entities whose
|
||||||
|
// alternate key is null, so we set it explicitly.
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "alice",
|
||||||
|
UserName = "alice",
|
||||||
|
Email = "alice@example.com",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long SeedPost(string authorId)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var post = new BlogPost
|
||||||
|
{
|
||||||
|
AuthorId = authorId,
|
||||||
|
Title = $"post-by-{authorId}",
|
||||||
|
Article = "test",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.BlogSpot.Add(post);
|
||||||
|
db.SaveChanges();
|
||||||
|
return post.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string PublishUrl(long id)
|
||||||
|
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/v1/blog/{id}/publish";
|
||||||
|
|
||||||
|
private string BlogsUrl
|
||||||
|
=> _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
|
||||||
|
|
||||||
|
private HttpClient NewClient(string subject)
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||||
|
"Bearer", TestTokenIssuer.Issue(subject));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutPublish_true_returns_204_and_sets_IsPublished_in_subsequent_GET()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
var postId = SeedPost("alice");
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
|
||||||
|
Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutPublish_false_returns_204_and_clears_IsPublished()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
var postId = SeedPost("alice");
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
|
||||||
|
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false });
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
|
||||||
|
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
|
||||||
|
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutPublish_on_unknown_post_returns_404()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true });
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutPublish_by_non_author_returns_challenge()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
var postId = SeedPost("alice");
|
||||||
|
|
||||||
|
using var http = NewClient("bob");
|
||||||
|
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
|
||||||
|
// 401 Challenge (the controller returns Challenge()
|
||||||
|
// for AuthorizationFailureException). The exact code
|
||||||
|
// is framework-dependent; what matters is "not 204".
|
||||||
|
Assert.NotEqual(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -139,9 +139,56 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return Ok(blog);
|
return Ok(blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle a post's publication state. <c>true</c> adds
|
||||||
|
/// a row to <c>blogSpotPublications</c> (the post
|
||||||
|
/// becomes publicly readable via
|
||||||
|
/// <c>PermissionHandler.IsPublic</c>); <c>false</c>
|
||||||
|
/// removes it.
|
||||||
|
///
|
||||||
|
/// <para>PUT (not POST) because the operation is
|
||||||
|
/// idempotent — the resulting state is determined by
|
||||||
|
/// the body, not by the request. Returns 204 No
|
||||||
|
/// Content on success, 404 when the post does not
|
||||||
|
/// exist, 403 (Challenge) when the caller is not the
|
||||||
|
/// author.</para>
|
||||||
|
/// </summary>
|
||||||
|
// PUT: api/BlogApi/5/publish
|
||||||
|
// body: { "publish": true }
|
||||||
|
[HttpPut("{id}/publish")]
|
||||||
|
public async Task<IActionResult> PutPublish(
|
||||||
|
[FromRoute] long id,
|
||||||
|
[FromBody] SetPublishBody body)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ok = await blogSpotService.SetPublishAsync(User, id, body.Publish);
|
||||||
|
if (!ok) return NotFound();
|
||||||
|
return new StatusCodeResult(StatusCodes.Status204NoContent);
|
||||||
|
}
|
||||||
|
catch (AuthorizationFailureException)
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire body for <c>PUT /api/BlogApi/{id}/publish</c>.
|
||||||
|
/// Intentionally tiny: just the desired publication state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SetPublishBody
|
||||||
|
{
|
||||||
|
public bool Publish { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,31 +41,31 @@ namespace Yavsc.Models
|
||||||
/// User's posts
|
/// User's posts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[InverseProperty("Author"), JsonIgnore]
|
[InverseProperty("Author"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
public virtual List<Blog.BlogPost>? Posts { get; set; }
|
public virtual List<Blog.BlogPost>? Posts { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// User's contact list
|
/// User's contact list
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[InverseProperty("Owner"), JsonIgnore]
|
[InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
public virtual List<Contact>? Book { get; set; }
|
public virtual List<Contact>? Book { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// External devices using the API
|
/// External devices using the API
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[InverseProperty("DeviceOwner"), JsonIgnore]
|
[InverseProperty("DeviceOwner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
public virtual List<DeviceDeclaration>? DeviceDeclaration { get; set; }
|
public virtual List<DeviceDeclaration>? DeviceDeclaration { get; set; }
|
||||||
|
|
||||||
[InverseProperty("Owner"), JsonIgnore]
|
[InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
public virtual List<ChatConnection>? Connections { get; set; }
|
public virtual List<ChatConnection>? Connections { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// User's circles
|
/// User's circles
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[InverseProperty("Owner"), JsonIgnore]
|
[InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
|
|
||||||
public virtual List<Circle>? Circles { get; set; }
|
public virtual List<Circle>? Circles { get; set; }
|
||||||
|
|
||||||
|
|
@ -96,28 +96,28 @@ namespace Yavsc.Models
|
||||||
|
|
||||||
public long MaxFileSize { get; set; } = 512 * 1024 * 1024;
|
public long MaxFileSize { get; set; } = 512 * 1024 * 1024;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
[InverseProperty("Owner")]
|
[InverseProperty("Owner")]
|
||||||
public virtual List<BlackListed>? BlackList { get; set; }
|
public virtual List<BlackListed>? BlackList { get; set; }
|
||||||
|
|
||||||
public bool AllowMonthlyEmail { get; set; } = false;
|
public bool AllowMonthlyEmail { get; set; } = false;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
[InverseProperty("Owner")]
|
[InverseProperty("Owner")]
|
||||||
public virtual List<ChatRoom>? Rooms { get; set; }
|
public virtual List<ChatRoom>? Rooms { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
[InverseProperty("User")]
|
[InverseProperty("User")]
|
||||||
public virtual List<ChatRoomAccess>? RoomAccess { get; set; }
|
public virtual List<ChatRoomAccess>? RoomAccess { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
[InverseProperty("Member")]
|
[InverseProperty("Member")]
|
||||||
public virtual List<CircleMember>? Membership { get; set; }
|
public virtual List<CircleMember>? Membership { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// User's blog comments
|
/// User's blog comments
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonIgnore]
|
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
[InverseProperty("Author")]
|
[InverseProperty("Author")]
|
||||||
public virtual List<Blog.Comment>? BlogComments { get; set; }
|
public virtual List<Blog.Comment>? BlogComments { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,18 @@ namespace Yavsc.Models.Blog
|
||||||
[InverseProperty("Post")]
|
[InverseProperty("Post")]
|
||||||
public virtual List<Comment> Comments { get; set; }
|
public virtual List<Comment> Comments { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this post is published. Not a column: the
|
||||||
|
/// existence of a row in <c>BlogSpotPublication</c>
|
||||||
|
/// is the source of truth. EF skips this property via
|
||||||
|
/// <c>[NotMapped]</c> so no migration is needed. The
|
||||||
|
/// service hydrates it after each fetch (single bulk
|
||||||
|
/// lookup, not N+1) and it surfaces through the wire
|
||||||
|
/// as part of the JSON-serialised <c>BlogPost</c>.
|
||||||
|
/// </summary>
|
||||||
|
[NotMapped]
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
|
||||||
IApplicationUser IBlogPost.Author => Author;
|
IApplicationUser IBlogPost.Author => Author;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,10 @@ public class BlogSpotService
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Hydrate the [NotMapped] IsPublished flag from the
|
||||||
|
// publication table so the wire JSON carries it.
|
||||||
|
blog.IsPublished = await _context.blogSpotPublications
|
||||||
|
.AnyAsync(pub => pub.BlogpostId == blogPostId);
|
||||||
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
|
||||||
if (!auth.Succeeded)
|
if (!auth.Succeeded)
|
||||||
{
|
{
|
||||||
|
|
@ -225,10 +229,31 @@ public class BlogSpotService
|
||||||
.Select(p => p.BlogPost).ToArray();
|
.Select(p => p.BlogPost).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = posts.OrderByDescending(p => p.DateModified)
|
// Materialise before hydrating IsPublished: it's a
|
||||||
|
// computed [NotMapped] property that needs to be set
|
||||||
|
// on each BlogPost instance after the query runs.
|
||||||
|
var materialised = posts.ToList();
|
||||||
|
|
||||||
|
// Single bulk lookup for the IsPublished flag — avoid
|
||||||
|
// the N+1 of one AnyAsync per post. The published ids
|
||||||
|
// are loaded once and matched against the post list
|
||||||
|
// in memory.
|
||||||
|
var postIds = materialised.OfType<BlogPost>().Select(p => p.Id).ToList();
|
||||||
|
if (postIds.Count > 0)
|
||||||
|
{
|
||||||
|
var publishedIds = await _context.blogSpotPublications
|
||||||
|
.Where(pub => postIds.Contains(pub.BlogpostId))
|
||||||
|
.Select(pub => pub.BlogpostId)
|
||||||
|
.ToListAsync();
|
||||||
|
var publishedSet = publishedIds.ToHashSet();
|
||||||
|
foreach (var post in materialised.OfType<BlogPost>())
|
||||||
|
post.IsPublished = publishedSet.Contains(post.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return materialised
|
||||||
|
.OrderByDescending(p => p.DateModified)
|
||||||
.Skip(skip)
|
.Skip(skip)
|
||||||
.Take(take);
|
.Take(take);
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Delete(ClaimsPrincipal user, long id)
|
public async Task Delete(ClaimsPrincipal user, long id)
|
||||||
|
|
@ -268,4 +293,55 @@ public class BlogSpotService
|
||||||
.SingleOrDefaultAsync(x => x.Id == value);
|
.SingleOrDefaultAsync(x => x.Id == value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle a post's publication state. <paramref name="publish"/>
|
||||||
|
/// true adds a row to <c>blogSpotPublications</c> (the post
|
||||||
|
/// becomes visible to anonymous callers via
|
||||||
|
/// <see cref="PermissionHandler.IsPublic"/>); false removes
|
||||||
|
/// the row if present.
|
||||||
|
///
|
||||||
|
/// <para>The post must already exist (caller must be the
|
||||||
|
/// author — this is gated by the controller's EditPermission
|
||||||
|
/// check). Returns false when the post does not exist; true
|
||||||
|
/// on a successful toggle.</para>
|
||||||
|
///
|
||||||
|
/// <para>This is the same toggle the
|
||||||
|
/// <see cref="BlogPostEditViewModel"/>-flavoured
|
||||||
|
/// <see cref="Modify(ClaimsPrincipal, BlogPostEditViewModel)"/>
|
||||||
|
/// overload performs inline; extracted here so the
|
||||||
|
/// /api/blog/{id}/publish endpoint can hit it without
|
||||||
|
/// forcing the caller to round-trip the full BlogPost in
|
||||||
|
/// the request body.</para>
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> SetPublishAsync(ClaimsPrincipal user, long postId, bool publish)
|
||||||
|
{
|
||||||
|
var blog = await _context.BlogSpot.SingleOrDefaultAsync(b => b.Id == postId);
|
||||||
|
if (blog == null) return false;
|
||||||
|
|
||||||
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
||||||
|
if (!auth.Succeeded)
|
||||||
|
{
|
||||||
|
throw new AuthorizationFailureException(auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await _context.blogSpotPublications.SingleOrDefaultAsync(
|
||||||
|
p => p.BlogpostId == postId);
|
||||||
|
if (publish)
|
||||||
|
{
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
_context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
_context.blogSpotPublications.Remove(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _context.SaveChangesAsync(user.GetUserId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue