release/1.0.7 #34
10 changed files with 395 additions and 12 deletions
Merge pull request 'feat(post): add Publish toggle for blog posts (no schema change)' (#35) from feat/postit-acl into release/1.0.7
Reviewed-on: #35
commit
b89b8bfccc
|
|
@ -35,6 +35,18 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
[ObservableProperty]
|
||||
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]
|
||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||
|
||||
|
|
@ -102,6 +114,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
WindowTitle = "PostIt";
|
||||
DraftTitle = string.Empty;
|
||||
DraftArticle = string.Empty;
|
||||
DraftIsPublished = false;
|
||||
CurrentViewModel = this;
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +144,9 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
// doesn't show stale content.
|
||||
DraftTitle = value?.Title ?? 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();
|
||||
}
|
||||
|
||||
|
|
@ -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]
|
||||
internal void OpenSettings()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@
|
|||
<Button Command="{Binding Delete}" Content="Delete" />
|
||||
<Button Command="{Binding ManageAcl}" Content="ACL" />
|
||||
<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
|
||||
capture page. Production entry point is a SignalR
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@ public class BlogPostDto : IBlogPost
|
|||
public string UserModified { 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)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
|
|
|
|||
|
|
@ -70,4 +70,15 @@ public sealed class BlogApiClient
|
|||
|
||||
public Task DeletePostAsync(long id, CancellationToken ct = default)
|
||||
=> _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)
|
||||
{
|
||||
// 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.UseAuthentication();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[InverseProperty("Author"), JsonIgnore]
|
||||
[InverseProperty("Author"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
public virtual List<Blog.BlogPost>? Posts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User's contact list
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[InverseProperty("Owner"), JsonIgnore]
|
||||
[InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
public virtual List<Contact>? Book { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// External devices using the API
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[InverseProperty("DeviceOwner"), JsonIgnore]
|
||||
[InverseProperty("DeviceOwner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// User's circles
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[InverseProperty("Owner"), JsonIgnore]
|
||||
[InverseProperty("Owner"), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
|
||||
public virtual List<Circle>? Circles { get; set; }
|
||||
|
||||
|
|
@ -96,28 +96,28 @@ namespace Yavsc.Models
|
|||
|
||||
public long MaxFileSize { get; set; } = 512 * 1024 * 1024;
|
||||
|
||||
[JsonIgnore]
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
[InverseProperty("Owner")]
|
||||
public virtual List<BlackListed>? BlackList { get; set; }
|
||||
|
||||
public bool AllowMonthlyEmail { get; set; } = false;
|
||||
|
||||
[JsonIgnore]
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
[InverseProperty("Owner")]
|
||||
public virtual List<ChatRoom>? Rooms { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
[InverseProperty("User")]
|
||||
public virtual List<ChatRoomAccess>? RoomAccess { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
[InverseProperty("Member")]
|
||||
public virtual List<CircleMember>? Membership { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User's blog comments
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
[InverseProperty("Author")]
|
||||
public virtual List<Blog.Comment>? BlogComments { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,18 @@ namespace Yavsc.Models.Blog
|
|||
[InverseProperty("Post")]
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ public class BlogSpotService
|
|||
{
|
||||
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());
|
||||
if (!auth.Succeeded)
|
||||
{
|
||||
|
|
@ -225,10 +229,31 @@ public class BlogSpotService
|
|||
.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)
|
||||
.Take(take);
|
||||
return data;
|
||||
}
|
||||
|
||||
public async Task Delete(ClaimsPrincipal user, long id)
|
||||
|
|
@ -268,4 +293,55 @@ public class BlogSpotService
|
|||
.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