feat(post): add Publish toggle for blog posts (no schema change) #35
10 changed files with 395 additions and 12 deletions
|
|
@ -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