Compare commits
No commits in common. "b89b8bfccca716040fd3aed51f1cbaf1d5c52860" and "e79f6423db79f5a3c508634c15c19a1cb9e9a00f" have entirely different histories.
b89b8bfccc
...
e79f6423db
10 changed files with 12 additions and 395 deletions
|
|
@ -35,18 +35,6 @@ 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; }
|
||||||
|
|
||||||
|
|
@ -114,7 +102,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,9 +131,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,46 +241,6 @@ 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,18 +35,6 @@
|
||||||
<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,18 +19,6 @@ 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,15 +70,4 @@ 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,12 +154,6 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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,56 +139,9 @@ 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, System.Text.Json.Serialization.JsonIgnore]
|
[InverseProperty("Author"), 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, System.Text.Json.Serialization.JsonIgnore]
|
[InverseProperty("Owner"), 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, System.Text.Json.Serialization.JsonIgnore]
|
[InverseProperty("DeviceOwner"), JsonIgnore]
|
||||||
public virtual List<DeviceDeclaration>? DeviceDeclaration { get; set; }
|
public virtual List<DeviceDeclaration>? DeviceDeclaration { get; set; }
|
||||||
|
|
||||||
[InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
[InverseProperty("Owner"), 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, System.Text.Json.Serialization.JsonIgnore]
|
[InverseProperty("Owner"), 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, System.Text.Json.Serialization.JsonIgnore]
|
[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, System.Text.Json.Serialization.JsonIgnore]
|
[JsonIgnore]
|
||||||
[InverseProperty("Owner")]
|
[InverseProperty("Owner")]
|
||||||
public virtual List<ChatRoom>? Rooms { get; set; }
|
public virtual List<ChatRoom>? Rooms { get; set; }
|
||||||
|
|
||||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
[JsonIgnore]
|
||||||
[InverseProperty("User")]
|
[InverseProperty("User")]
|
||||||
public virtual List<ChatRoomAccess>? RoomAccess { get; set; }
|
public virtual List<ChatRoomAccess>? RoomAccess { get; set; }
|
||||||
|
|
||||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
[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, System.Text.Json.Serialization.JsonIgnore]
|
[JsonIgnore]
|
||||||
[InverseProperty("Author")]
|
[InverseProperty("Author")]
|
||||||
public virtual List<Blog.Comment>? BlogComments { get; set; }
|
public virtual List<Blog.Comment>? BlogComments { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,18 +95,6 @@ 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,10 +115,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -229,31 +225,10 @@ public class BlogSpotService
|
||||||
.Select(p => p.BlogPost).ToArray();
|
.Select(p => p.BlogPost).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materialise before hydrating IsPublished: it's a
|
var data = posts.OrderByDescending(p => p.DateModified)
|
||||||
// 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)
|
||||||
|
|
@ -293,55 +268,4 @@ 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