Compare commits

..

No commits in common. "1167169aa89e1bf25290e9a152d27b357a500ab3" and "6e50967702ba9d310017c86a2d7ee636a9e94ada" have entirely different histories.

65 changed files with 310 additions and 3154 deletions

View file

@ -16,111 +16,15 @@ Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) [`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`. pour la production des paquets `.deb`.
## [1.0.7] - preview ## [Unreleased]
### Added ### Added
- Per-post ACL in PostIt: a new “Manage ACL” page, opened from the ACL
button on a selected post, lets the post author grant or revoke
grants for individuals or circles. The server scopes each grant
operation to `caller == post.AuthorId` and returns `404` (not `403`)
for posts the caller does not own, so the existence of another
user's post is not leaked.
- Circle membership API + UI: three new REST endpoints under
`/api/circle/{id}/members` (`GET` list, `POST` add, `DELETE`
remove) and a new “Members” column on the *My Circles* page with an
“Add a member” button that opens a search modal. The search modal
reuses `IUserDirectory` (introduced by the `IContactService` split
in this same release) — exactly the use case the abstraction was
carved out for.
- Publish toggle for blog posts: a new `PUT /api/BlogApi/{id}/publish`
endpoint, and a `Published` checkbox in the post toolbar that
toggles a `BlogSpotPublication` row for the post. The publish
signal flows through the pre-existing `PermissionHandler.IsPublic`
path, so no new column was needed and the server-side authorisation
logic is unchanged.
- `UserSearchApiController` in `Yavsc.Blogs`:
`GET /api/user-search?q=...&e=...&take=...`. Any-authenticated-
caller endpoint that exposes the user's email under a closed-
community assumption (documented in the controller's XML doc).
Wired to the PostIt Desktop address book so the user search modal
picks it up.
- `IYavscApiClient` abstraction in `Yavsc.Api.Client`. The transport
for the blog/circle/blog-acl/user-search clients is now accessed
through this interface, so `PostIt.Tests` can stub the HTTP layer
without spinning up a real WebAPI host.
- Forgejo Actions release workflow: a `.forgejo/workflows/release.yml`
pipeline that builds and publishes a release with the PostIt APK
on tag push. Written in pure bash (the runner image has no Node),
uses `jq` for JSON body construction and response parsing, uses the
runner-provided `GITHUB_TOKEN` (no repo-level secret needed),
validates the CHANGELOG section heading before allowing the tag
to ship.
- `make release V=<version>` target: creates a `release/<V>` branch
from `main`, bumps the `<Version>` property in every `.csproj` via
`dotnet-gitversion /updateprojectfiles`, commits the bump on the
release branch, and pushes to `origin`. Fails fast if the working
tree is dirty or if `HEAD` is not on `main`.
- Forgejo status badges in the README.
### Changed ### Changed
- The new Publish toggle replaces the “Visibility enum” approach
originally drafted in this branch: the existing `BlogSpotPublication`
table already carried enough information to expose a publish
switch, so no schema change was needed. The original `feat(blog):
add Visibility { Private, Public }` commit and its EF migration
were reverted in favour of the endpoint-only toggle.
- `BlogPost` DTO and `IBlogPost` moved from `PostIt.Models` to
`Yavsc.Abstract.Blogspot`, the shared assembly where the server-side
entity and the wire DTO both live. Renamed `Yavsc.Blogspot.BlogPost`
to `BlogPostDto` to make the wire/entity distinction explicit.
- `BlogAclApiController` and `CircleApiController` moved from
`Yavsc.Api` (not yet enabled in production) to `Yavsc.Blogs`, where
they belong next to the `BlogSpotService` they depend on.
- `IContactService` split from `IUserDirectory`: the two interfaces
previously conflated the local address-book access (mobile-only,
via `Contacts.Default`) and the Yavsc user-search access
(Desktop-only, via `/api/user-search`) behind a single facade. The
split restores the `ContactDto.Emails` multi-value shape that was
being silently flattened to a single string before.
- CI: the Forgejo Actions build now compiles `.csproj` projects
directly inside the runner container (which ships the .NET SDK +
Android workload), instead of relying on a separate Docker build
step. Node-based third-party actions were replaced with bash + curl
+ `jq`. The validate-release job parses the CHANGELOG section
heading to derive the channel (`stable` / `preview` / `unstable`)
rather than the patch-version parity alone.
### Fixed ### Fixed
- `CircleApiController` used to read the caller's user id via
`FindFirstValue(ClaimTypes.NameIdentifier)`, which does not match
when JWT Bearer middleware has `MapInboundClaims = false`. Switched
to `User.GetUserId()` (tries `sub` first, then
`ClaimTypes.NameIdentifier`, then `nameid`). This was a latent
bug visible in tests but easy to ship to production if a host
ever disabled the remap.
- `CircleApiController` and `BlogAclApiController` reads and writes
were not always scoped to the caller's own data. Tightened the
authorisation checks: cross-user reads now return `404`, not the
raw record.
- `validate-release` CHANGELOG channel check used to parse the
patch-version parity only, which disagreed with the channel
suffix in the section heading (e.g. `## [1.0.7] - preview`
would be flagged as `stable` from the parity alone). The job now
inspects the heading line and trusts the suffix when present.
- `.forgejo/workflows/release.yml`: the asset-upload URL now carries
the asset name as a query-string parameter instead of a `curl`
positional argument. The previous shape triggered Forgejo's
“Missing `name` parameter” 400 in some cases.
### Removed ### Removed
- The `## [Unreleased]` block has been moved into this section.
- The abandoned `Visibility { Private, Public }` enum and its EF
migration, reverted in this release. The publish toggle covers
the same user-visible switch without a schema change.
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD
[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6
## [1.0.6] - stable ## [1.0.6] - stable
@ -154,4 +58,5 @@ pour la production des paquets `.deb`.
actual release id. Switched to `jq` for both body construction and actual release id. Switched to `jq` for both body construction and
field extraction. field extraction.
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 [1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6

View file

@ -4,17 +4,6 @@
C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé. C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé.
# Statut actuel des actions Forgejo
[![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml)
[![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)](
https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml
)
[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest)
# Statut actuel des actions GitHub # Statut actuel des actions GitHub
* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) * [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)

View file

@ -8,9 +8,6 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.Services; using PostIt.Services;
using Xunit; using Xunit;
@ -97,7 +94,7 @@ public class BearerScopeTests
// CapturingHttpHandler is the assertion point. It // CapturingHttpHandler is the assertion point. It
// records the first request's Authorization header and // records the first request's Authorization header and
// returns 200 with an empty array (BlogApiClient // returns 200 with an empty array (BlogApiClient
// deserialises to List<BlogPostDto>). // deserialises to List<BlogPost>).
var captured = new CapturingHttpHandler(); var captured = new CapturingHttpHandler();
var client = new YavscApiClient( var client = new YavscApiClient(
settings, settings,
@ -122,7 +119,7 @@ public class BearerScopeTests
// Resolve a BlogApiClient on top. We don't need real // Resolve a BlogApiClient on top. We don't need real
// posts; we just need the outbound HTTP request to be // posts; we just need the outbound HTTP request to be
// the one we capture. // the one we capture.
var blog = new BlogApiClient(subClient, "http://localhost/"); var blog = new BlogApiClient(subClient);
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);

View file

@ -1,4 +1,4 @@
using Yavsc.Blogspot; using PostIt.Models;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc.Models; using Yavsc.Models;
@ -18,7 +18,7 @@ internal sealed class CallRecorder
/// <summary>Test fake that records every CallAsync invocation /// <summary>Test fake that records every CallAsync invocation
/// and answers them with a canned sequence: the first call gets /// and answers them with a canned sequence: the first call gets
/// a server-issued BlogPostDto (Id=42), the second call gets a /// a server-issued BlogPost (Id=42), the second call gets a
/// single-element list containing that post. Used by the ViewModel /// single-element list containing that post. Used by the ViewModel
/// tests and the headless UI test to capture exactly what the /// tests and the headless UI test to capture exactly what the
/// Save button posts to the server.</summary> /// Save button posts to the server.</summary>
@ -44,20 +44,20 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
_recorder.Calls.Add((method, path, body)); _recorder.Calls.Add((method, path, body));
// BlogPostDto? boxes to BlogPostDto at runtime, so we test the // BlogPost? boxes to BlogPost at runtime, so we test the
// non-nullable type — typeof(BlogPostDto?) is a C# error // non-nullable type — typeof(BlogPost?) is a C# error
// (CS8639: "typeof cannot be used on a nullable reference // (CS8639: "typeof cannot be used on a nullable reference
// type"). // type").
if (typeof(T) == typeof(BlogPostDto)) if (typeof(T) == typeof(BlogPost))
return Task.FromResult((T)(object)new BlogPostDto return Task.FromResult((T)(object)new BlogPost
{ {
Id = 42, Id = 42,
Title = "Mon premier billet", Title = "Mon premier billet",
AuthorId = "tester", AuthorId = "tester",
Article = "Contenu du billet de test.", Article = "Contenu du billet de test.",
}); });
if (typeof(T) == typeof(List<BlogPostDto>)) if (typeof(T) == typeof(List<BlogPost>))
return Task.FromResult((T)(object)new List<BlogPostDto> return Task.FromResult((T)(object)new List<BlogPost>
{ {
new() { Id = 42, Title = "Mon premier billet" } new() { Id = 42, Title = "Mon premier billet" }
}); });

View file

@ -2,8 +2,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Yavsc.Blogspot; using PostIt.Models;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -25,7 +24,7 @@ namespace PostIt.Tests;
/// in which a brand-new post can be created), the binding has /// in which a brand-new post can be created), the binding has
/// no target and the user's keystrokes are silently dropped. /// no target and the user's keystrokes are silently dropped.
/// Clicking "Save" then routes to the VM branch /// Clicking "Save" then routes to the VM branch
/// <c>if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } }</c> /// <c>if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } }</c>
/// which the controller rejects with 400 "The Title field is /// which the controller rejects with 400 "The Title field is
/// required." This test fails on that branch today and will /// required." This test fails on that branch today and will
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c> /// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
@ -41,7 +40,7 @@ public class MainPageSaveTests
// not a Control, so it needs a navigation host). // not a Control, so it needs a navigation host).
var recorder = new CallRecorder(); var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder); var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api);
var viewModel = new MainPageViewModel(blog); var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel }; var page = new MainPage { DataContext = viewModel };
@ -77,14 +76,14 @@ public class MainPageSaveTests
// we inspect the recorder. // we inspect the recorder.
await Task.Delay(200); await Task.Delay(200);
// Assert: the first POST to "blog" carried a BlogPostDto // Assert: the first POST to "blog" carried a BlogPost
// whose Title is exactly what the user typed. The bug // whose Title is exactly what the user typed. The bug
// fails this assertion with Title == string.Empty. // fails this assertion with Title == string.Empty.
Assert.NotEmpty(recorder.Calls); Assert.NotEmpty(recorder.Calls);
var (method, path, body) = recorder.FirstCall; var (method, path, body) = recorder.FirstCall;
Assert.Equal(HttpMethod.Post, method); Assert.Equal(HttpMethod.Post, method);
Assert.Equal("blog", path); Assert.Equal("blog", path);
var sent = Assert.IsType<BlogPostDto>(body); var sent = Assert.IsType<BlogPost>(body);
Assert.Equal(typed, sent.Title); Assert.Equal(typed, sent.Title);
} }
} }

View file

@ -6,10 +6,10 @@
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>PostIt.Tests</RootNamespace> <RootNamespace>PostIt.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />

View file

@ -1,5 +1,4 @@
using Yavsc.Blogspot; using PostIt.Models;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -15,12 +14,12 @@ public class PostItViewModelTests
// default; tests construct one with a fake YavscApiClient that // default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test). // throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient(); var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/"); var blog = new BlogApiClient(fakeApi);
var viewModel = new MainPageViewModel(blog); var viewModel = new MainPageViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); viewModel.Posts.Add(new BlogPost { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search"; viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null); viewModel.SearchCommand.Execute(null);
@ -41,13 +40,13 @@ public class PostItViewModelTests
// The new BlogApiClient delegates transport to YavscApiClient. // The new BlogApiClient delegates transport to YavscApiClient.
// We feed it a fake YavscApiClient that returns the expected // We feed it a fake YavscApiClient that returns the expected
// list straight from CallAsync. // list straight from CallAsync.
var expected = new List<BlogPostDto> var expected = new List<BlogPost>
{ {
new() { Id = 1, Title = "Hello" }, new() { Id = 1, Title = "Hello" },
new() { Id = 2, Title = "World" } new() { Id = 2, Title = "World" }
}; };
var api = new StubYavscApiClient(expected); var api = new StubYavscApiClient(expected);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api);
var posts = await blog.GetPostsAsync(); var posts = await blog.GetPostsAsync();
@ -77,8 +76,8 @@ public class PostItViewModelTests
/// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary> /// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary>
private sealed class StubYavscApiClient : YavscApiClient private sealed class StubYavscApiClient : YavscApiClient
{ {
private readonly List<BlogPostDto> _posts; private readonly List<BlogPost> _posts;
public StubYavscApiClient(List<BlogPostDto> posts) public StubYavscApiClient(List<BlogPost> posts)
: base( : base(
new Settings new Settings
{ {
@ -98,7 +97,7 @@ public class PostItViewModelTests
{ {
// The canned fake only knows about a list of posts; the // The canned fake only knows about a list of posts; the
// BlogApiClient test asserts on that list directly. // BlogApiClient test asserts on that list directly.
if (typeof(T) == typeof(List<BlogPostDto>)) if (typeof(T) == typeof(List<BlogPost>))
return Task.FromResult((T)(object)_posts); return Task.FromResult((T)(object)_posts);
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }

View file

@ -8,9 +8,6 @@ using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;

View file

@ -14,7 +14,6 @@
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.17.0" /> <PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
</ItemGroup> </ItemGroup>

View file

@ -12,10 +12,10 @@
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers> <RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">

View file

@ -4,10 +4,10 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Browser" /> <PackageReference Include="Avalonia.Browser" />

View file

@ -5,10 +5,10 @@
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.--> See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -7,7 +7,6 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -56,12 +55,7 @@ public partial class App : Application
"PostIt", "tokens.json")); "PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore); var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl); var client = new BlogApiClient(api);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection(); var services = new ServiceCollection();
@ -81,22 +75,14 @@ public partial class App : Application
services.AddSingleton<SettingsPage>(); services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>(); services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>(); services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api); services.AddSingleton(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client); services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>(); services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>(); services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>(); services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of // Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation. // the app so the same VM survives page navigation.

View file

@ -0,0 +1,37 @@
using System;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Blogspot;
namespace PostIt.Models;
public class BlogPost : IBlogPost
{
public string AuthorId { get; set; }
public IApplicationUser Author { get; set; }
public string Article { get; set ; }
public string Photo { get; set ; }
public long Id { get; set ; }
public DateTime DateCreated { get; set ; }
public string UserCreated { get; set ; }
public DateTime DateModified { get; set ; }
public string UserModified { get; set ; }
public string Title { get; set ; }
public bool AuthorizeCircle(long circleId)
{
throw new NotImplementedException();
}
public ICircleAuthorization[] GetACL()
{
throw new NotImplementedException();
}
public string[] GetTags()
{
throw new NotImplementedException();
}
}

View file

@ -4,10 +4,10 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -25,7 +25,6 @@
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="postit-settings.json"> <Content Include="postit-settings.json">

View file

@ -3,18 +3,17 @@ using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yavsc.Blogspot; using PostIt.Models;
namespace Yavsc.Api.Client; namespace PostIt.Services;
/// <summary> /// <summary>
/// High-level client for the Blog subsystem of the Yavsc API /// High-level client for the Blog subsystem of the Yavsc API
/// (deployed at <c>https://blogs.pschneider.fr</c>). All transport /// (deployed at <c>https://blogs.pschneider.fr</c>). All transport
/// concerns — base URL, JSON serialisation, Bearer auth, silent /// concerns — base URL, JSON serialisation, Bearer auth, silent
/// refresh on 401, request body shaping — are delegated to /// refresh on 401, request body shaping — are delegated to
/// <see cref="YavscApiClient"/>, which lives in the consuming /// <see cref="YavscApiClient"/>. This class is a thin DTO↔path
/// application (PostIt). This class is a thin DTO↔path mapper, /// mapper, nothing more.
/// nothing more.
/// ///
/// <para><b>URL convention.</b> <see cref="YavscApiClient"/>'s /// <para><b>URL convention.</b> <see cref="YavscApiClient"/>'s
/// <c>BaseAddress</c> already terminates with <c>/api/v1/</c> /// <c>BaseAddress</c> already terminates with <c>/api/v1/</c>
@ -35,50 +34,35 @@ public sealed class BlogApiClient
{ {
private const string DefaultPathPrefix = "blog"; private const string DefaultPathPrefix = "blog";
private readonly IYavscApiClient _api; private readonly YavscApiClient _api;
private readonly Uri _baseAddress;
private readonly string _pathPrefix; private readonly string _pathPrefix;
public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix) public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
{ {
_api = api ?? throw new ArgumentNullException(nameof(api)); _api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
// e.g. "https://blogs.pschneider.fr/api/v1/" — keep the // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly. // trailing slash so relative paths ("posts") resolve correctly.
_baseAddress = new Uri(blogsBaseAddress); api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl);
api.Http.BaseAddress = _baseAddress;
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
} }
public Task<List<BlogPostDto>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default) public Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync<List<BlogPostDto>>( => _api.CallAsync<List<BlogPost>>(
HttpMethod.Get, HttpMethod.Get,
$"{_pathPrefix}?start={start}&take={take}", $"{_pathPrefix}?start={start}&take={take}",
ct: ct); ct: ct);
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default) public Task<BlogPost?> GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct); => _api.CallAsync<BlogPost?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public Task<BlogPostDto?> CreatePostAsync(BlogPostDto post, CancellationToken ct = default) public Task<BlogPost?> CreatePostAsync(BlogPost post, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct); => _api.CallAsync<BlogPost?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default) public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct); => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
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);
} }

View file

@ -1,36 +0,0 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Desktop stub for <see cref="IContactService"/>.
///
/// <para>The desktop has no equivalent of the mobile address
/// book (no <c>Contacts.Default</c>, no CardDAV out of the
/// box). Rather than synthesise a list from a different
/// source, this provider returns an empty list and lets the
/// UI render an honest "no local contacts on this platform"
/// message.</para>
///
/// <para>If desktop users want to invite people who aren't
/// Yavsc members, that flow goes through a separate path
/// (manual email entry + invitation endpoint) — not through
/// <see cref="IContactService"/>. Finding existing Yavsc
/// members is <see cref="IUserDirectory"/>'s job, not this
/// one's.</para>
///
/// <para>Future CardDAV / Google Contacts / Exchange
/// providers can plug in here as additional
/// <see cref="IContactService"/> implementations selected
/// from DI by configuration.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
}
#endif

View file

@ -1,82 +0,0 @@
#if ANDROID || IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Devices;
namespace PostIt.Services;
/// <summary>
/// Mobile implementation backed by MAUI Essentials
/// <c>Contacts.Default</c>.
///
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
/// compile time).</para>
///
/// <para>Note: at runtime, this class throws
/// <c>NotImplementedInReferenceAssemblyException</c> unless
/// the host application project also references the
/// platform-specific Microsoft.Maui.Essentials implementation
/// (typically <c>PostIt.Android</c>). On iOS the same is
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
/// and this file is excluded.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
{
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
return Array.Empty<ContactDto>();
try
{
var status = await Permissions.RequestAsync<Permissions.ContactsRead>();
if (status != PermissionStatus.Granted)
return Array.Empty<ContactDto>();
var contacts = await Contacts.Default.GetAllAsync();
if (contacts is null) return Array.Empty<ContactDto>();
// Carry the per-contact email list as-is. A real
// device contact can carry several addresses (home /
// work / other); the UI use case ("invite / add to a
// circle") can then decide which address to use, or
// let the user pick. The platform-neutral ContactDto
// shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows
// answer different questions.
var result = new List<ContactDto>(contacts.Count);
foreach (var c in contacts)
{
var emails = ExtractEmails(c.Emails);
result.Add(new ContactDto(
c.Id,
c.DisplayName ?? string.Empty,
emails));
}
return result;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}");
return Array.Empty<ContactDto>();
}
}
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
{
if (emails is null) return Array.Empty<string>();
var list = new List<string>();
foreach (var e in emails)
{
if (!string.IsNullOrEmpty(e.EmailAddress))
list.Add(e.EmailAddress);
}
return list;
}
}
#endif

View file

@ -1,57 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Abstraction over the device-local address book. Used by
/// the "invite someone" flow to enumerate people the user
/// already has in their phone — including people who have
/// never heard of Yavsc.
///
/// <para>Distinct from <see cref="IUserDirectory"/>, which
/// reads the central Yavsc user table. A device contact may
/// not have a Yavsc account; a directory entry always does.
/// The two are exposed as separate interfaces so a UI that
/// needs both can take both by constructor injection and
/// present them under separate sections (e.g. "Contacts from
/// your phone" vs "Yavsc members").</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>ContactService.Mobile.cs</c> (ANDROID/IOS) and
/// <c>ContactService.Desktop.cs</c> (everything else). On
/// desktop the implementation is a stub that returns an
/// empty list: the desktop has no equivalent of the mobile
/// address book, and inviting from a desktop is a separate
/// flow.</para>
/// </summary>
public interface IContactService
{
/// <summary>
/// Read the device address book. Returns the contacts
/// known to the local provider; on desktop (no local
/// provider) this is always an empty list.
/// </summary>
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
}
/// <summary>
/// Platform-neutral contact DTO. Source-of-truth shape for
/// the UI layer; concrete providers (MAUI Essentials on
/// mobile) map to this type.
///
/// <para><c>Emails</c> is a list on purpose: a real device
/// contact may carry several addresses (home / work / other).
/// The UI use case ("invite / add to a circle") can then
/// decide which address to use, or let the user pick. This
/// is intentionally richer than the Yavsc directory's
/// single-<c>Email</c> shape — the two flows answer different
/// questions and shouldn't be flattened onto the same
/// wire.</para>
/// </summary>
public sealed record ContactDto(
string Id,
string DisplayName,
IReadOnlyList<string> Emails);

View file

@ -1,67 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Abstraction over the central Yavsc user directory. Used by
/// the "add to a circle" flow to find Yavsc users by display
/// name or email.
///
/// <para>Distinct from <see cref="IContactService"/>, which
/// reads the device-local address book. A Yavsc user
/// directory entry is always a registered account; a device
/// contact may be anyone in the user's phone — including
/// people who have never heard of Yavsc.</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>UserDirectory.Desktop.cs</c> and
/// <c>UserDirectory.Mobile.cs</c>. Both currently delegate to
/// <c>UserSearchClient</c> (the central <c>/api/user-search</c>
/// endpoint); the split exists so future platform-specific
/// sources (offline cache, directory-scoped providers) can be
/// plugged in without disturbing the consumer.</para>
/// </summary>
public interface IUserDirectory
{
/// <summary>
/// Search the directory by display name (substring) and/or
/// email (exact).
/// </summary>
/// <param name="query">Substring filter on the user's
/// display name. Empty or whitespace short-circuits to an
/// empty list (matches the client UX of "type to search",
/// not "show me a directory").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A flat list of matching directory entries.
/// Never null; may be empty.</returns>
Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default);
}
/// <summary>
/// Platform-neutral summary of a Yavsc directory entry. Mirrors
/// the wire shape of <c>/api/user-search</c> (see
/// <c>UserSearchResultDto</c>) but expressed in terms that
/// don't leak transport concerns.
///
/// <para>Kept as a record on purpose: directory entries are
/// immutable snapshots from the server, so structural equality
/// makes "did the user already pick this one?" trivial.</para>
/// </summary>
public sealed record UserSummary(
string Id,
string UserName,
string? FullName,
string? Avatar,
string? Email)
{
/// <summary>
/// Convenience for "what to show in a picker". Falls back
/// to <see cref="UserName"/> when <see cref="FullName"/>
/// is null or empty.
/// </summary>
public string DisplayName =>
string.IsNullOrWhiteSpace(FullName) ? UserName : FullName;
}

View file

@ -1,52 +0,0 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client;
namespace PostIt.Services;
/// <summary>
/// Desktop implementation of <see cref="IUserDirectory"/>.
/// Delegates to the central <c>/api/user-search</c> endpoint
/// via <see cref="UserSearchClient"/>.
///
/// <para>The desktop has no device-local address book, so the
/// "add to a circle" flow on desktop is Yavsc-users-only.
/// Inviting someone who doesn't have a Yavsc account from
/// desktop is a separate feature (manual email entry +
/// invitation endpoint) and lives outside this interface.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
string query, CancellationToken ct = default)
{
// UserSearchClient already short-circuits on empty
// queries, but do it here too so the contract is
// obvious to anyone reading IUserDirectory alone
// without having to chase the client wrapper.
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif

View file

@ -1,49 +0,0 @@
#if ANDROID || IOS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client;
namespace PostIt.Services;
/// <summary>
/// Mobile implementation of <see cref="IUserDirectory"/>.
/// Same backing as the desktop provider (the central
/// <c>/api/user-search</c> endpoint via
/// <see cref="UserSearchClient"/>) — mobile devices have the
/// network too, and "add to a circle" needs the same directory
/// regardless of platform.
///
/// <para>The split exists so a future mobile-only provider
/// (offline cache, device-local mirror of the user's own
/// circles) can be plugged in without touching consumers.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
string query, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif

View file

@ -9,7 +9,6 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc.Api.Client;
namespace PostIt.Services; namespace PostIt.Services;
@ -25,7 +24,7 @@ namespace PostIt.Services;
/// <see cref="BearerTokenHandler"/> only refreshes once even if many /// <see cref="BearerTokenHandler"/> only refreshes once even if many
/// concurrent requests are in flight. /// concurrent requests are in flight.
/// </summary> /// </summary>
public class YavscApiClient : IYavscApiClient, IAsyncDisposable public class YavscApiClient : IAsyncDisposable
{ {
// 60s of slack before the access_token's nominal expiry. Covers // 60s of slack before the access_token's nominal expiry. Covers
// network latency + JWT validation on the server side. // network latency + JWT validation on the server side.

View file

@ -1,118 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "add a Yavsc user to a circle" modal.
///
/// <para>Resolves users through <see cref="IUserDirectory"/>
/// (which delegates to <c>/api/user-search</c>); the caller
/// (CirclesPage) decides whether to add the picked user to
/// the circle by calling
/// <see cref="AddCircleMemberDialogViewModel.AddCommand"/>
/// (which is bound to the dialog's "Ajouter" button).</para>
///
/// <para>The dialog itself doesn't know the target
/// <c>CircleId</c>: that's set by the caller via the
/// constructor and the dialog only triggers
/// <see cref="IUserDirectory.SearchAsync"/> against the
/// <see cref="SearchQuery"/> string. The "Add" command
/// returns the picked <see cref="UserSummary"/> via the
/// <see cref="Confirmed"/> event, and the hosting
/// <c>CirclesPage</c> then calls
/// <see cref="CircleApiClient.AddMemberAsync"/>.</para>
/// </summary>
public partial class AddCircleMemberDialogViewModel : ViewModelBase
{
private readonly IUserDirectory _directory;
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<UserSummary> Results { get; set; } = new();
[ObservableProperty]
public partial UserSummary? Selected { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user confirms a selection. The hosting
/// <c>CirclesPage</c> subscribes to this event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id + the picked user's id. The dialog itself
/// does not know the circle id by design: separation of
/// concerns — the modal is a user picker, not a
/// "circle joiner" form.
/// </summary>
public event EventHandler<UserSummary>? Confirmed;
public AddCircleMemberDialogViewModel(IUserDirectory directory)
{
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
}
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
/// <summary>
/// Search the directory for users matching the current
/// <see cref="SearchQuery"/>. Triggered explicitly via the
/// "Rechercher" button — no debouncing, so the caller
/// stays in control of how often the network is hit.
/// </summary>
[RelayCommand]
public async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(SearchQuery))
{
Results.Clear();
StatusMessage = "Tapez un nom ou un email";
return;
}
IsBusy = true;
try
{
var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true);
Results = new ObservableCollection<UserSummary>(hits ?? Array.Empty<UserSummary>());
StatusMessage = $"{Results.Count} résultat(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Raise <see cref="Confirmed"/> for the currently selected
/// user. No-op when no selection has been made — keeps the
/// UI from firing an event with a null payload.
/// </summary>
[RelayCommand]
public void Add()
{
if (Selected is null)
{
StatusMessage = "Sélectionnez un utilisateur";
return;
}
Confirmed?.Invoke(this, Selected);
}
}

View file

@ -1,310 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "Mes cercles" page. CRUD on the caller's own
/// circles (the server scopes every endpoint to the caller's uid
/// since the BlogAcl fix on this branch), plus membership
/// management on the currently selected circle.
///
/// <para>The view lists circles in <see cref="Circles"/>, supports
/// create / edit via <see cref="DraftName"/>, and exposes
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
/// surfaces success / error feedback in the view footer.</para>
///
/// <para>When the user selects a circle in the list,
/// <see cref="LoadMembersAsync"/> fetches its members into
/// <see cref="Members"/>. The "Add a member" command
/// (<see cref="OpenAddMemberAsync"/>) is a UI event the view
/// raises to open <c>AddCircleMemberDialog</c>; the dialog
/// raises a <c>Confirmed</c> event back, which the page's
/// code-behind forwards here via
/// <see cref="OnAddMemberConfirmedAsync"/>. The "remove"
/// command is per-row and runs inline.</para>
/// </summary>
public partial class CirclesPageViewModel : ViewModelBase
{
private readonly CircleApiClient _client;
[ObservableProperty]
public partial ObservableCollection<CircleDto> Circles { get; set; } = new();
[ObservableProperty]
public partial CircleDto? SelectedCircle { get; set; }
/// <summary>Editor buffer for the new / edited circle's name.</summary>
[ObservableProperty]
public partial string DraftName { get; set; } = string.Empty;
/// <summary>Editor buffer for the new / edited circle's visibility flag.</summary>
[ObservableProperty]
public partial bool DraftPublic { get; set; }
/// <summary>Members of the currently selected circle. Empty
/// when no circle is selected or after a refresh that
/// produced an empty list. Updated by
/// <see cref="LoadMembersAsync"/>.</summary>
[ObservableProperty]
public partial ObservableCollection<CircleMemberDto> Members { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user wants to add a member to the
/// currently selected circle. The view listens to this
/// event and opens <c>AddCircleMemberDialog</c>.
/// </summary>
public event EventHandler? AddMemberRequested;
public CirclesPageViewModel(CircleApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
/// <summary>
/// Partial property setter: when the selected circle
/// changes, refresh the members list. The setter is
/// invoked by the [ObservableProperty] source generator
/// for both user selections and programmatic resets.
/// </summary>
partial void OnSelectedCircleChanged(CircleDto? value)
{
Members = new ObservableCollection<CircleMemberDto>();
if (value is not null)
{
// Fire-and-forget: load members in the background.
// Errors are routed to StatusMessage inside
// LoadMembersAsync.
_ = LoadMembersAsync(value.Id);
}
}
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _client.GetMyCirclesAsync();
Circles = new ObservableCollection<CircleDto>(list ?? new());
StatusMessage = $"{Circles.Count} cercle(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Load the members of one of the caller's circles. The
/// server scopes the endpoint with a 404 when the circle
/// doesn't belong to the caller (mirroring the rest of the
/// circle API); that case flattens to an empty list here.
/// </summary>
[RelayCommand]
public async Task LoadMembersAsync(long circleId)
{
IsBusy = true;
try
{
var list = await _client.GetMembersAsync(circleId);
Members = new ObservableCollection<CircleMemberDto>(list ?? new());
StatusMessage = $"{Members.Count} membre(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
Members = new ObservableCollection<CircleMemberDto>();
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public void StartCreate()
{
SelectedCircle = null;
DraftName = string.Empty;
DraftPublic = false;
StatusMessage = "Nouveau cercle";
}
[RelayCommand]
public void StartEdit(CircleDto? circle)
{
if (circle is null) return;
SelectedCircle = circle;
DraftName = circle.Name;
DraftPublic = circle.Public;
StatusMessage = $"Édition de « {circle.Name} »";
}
[RelayCommand]
public async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(DraftName))
{
StatusMessage = "Le nom est obligatoire";
return;
}
IsBusy = true;
try
{
if (SelectedCircle is null)
{
var created = await _client.CreateCircleAsync(new CircleDto
{
Name = DraftName.Trim(),
Public = DraftPublic,
});
StatusMessage = created is null
? "Création échouée"
: $"Cercle « {created.Name} » créé";
}
else
{
SelectedCircle.Name = DraftName.Trim();
SelectedCircle.Public = DraftPublic;
await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle);
StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour";
}
await RefreshAsync();
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task DeleteAsync(CircleDto? circle)
{
if (circle is null) return;
IsBusy = true;
try
{
await _client.DeleteCircleAsync(circle.Id);
StatusMessage = $"Cercle « {circle.Name} » supprimé";
// If the deleted circle was the selected one,
// clear the selection so the Members view goes
// empty too (the partial setter on
// SelectedCircle will reset Members).
if (SelectedCircle?.Id == circle.Id)
SelectedCircle = null;
await RefreshAsync();
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Fire the <see cref="AddMemberRequested"/> event so
/// the view opens <c>AddCircleMemberDialog</c>. The view
/// forwards the dialog's <c>Confirmed</c> event back to
/// <see cref="OnAddMemberConfirmedAsync"/>.
/// </summary>
[RelayCommand]
public void OpenAddMember()
{
if (SelectedCircle is null)
{
StatusMessage = "Sélectionnez d'abord un cercle";
return;
}
AddMemberRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Called by the view when the dialog confirms a
/// selection. Adds the picked user to the currently
/// selected circle and refreshes the members list.
/// </summary>
public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked)
{
if (SelectedCircle is null || picked is null) return;
IsBusy = true;
try
{
await _client.AddMemberAsync(SelectedCircle.Id, picked.Id);
StatusMessage = $"« {picked.DisplayName} » ajouté au cercle";
await LoadMembersAsync(SelectedCircle.Id);
}
catch (Exception ex)
{
// 409 (already a member) is a likely race — surface
// it as a friendly status, not an error. The
// server returns 409 for "already a member";
// YavscApiClient surfaces that as an exception
// today; future refactors could route 409 into a
// typed result, but for now the message string is
// distinctive enough.
var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict")
? "Déjà membre du cercle"
: $"Erreur: {ex.Message}";
StatusMessage = msg;
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Per-row "remove" command. Updates the local
/// collection in place so the UI doesn't flash.
/// </summary>
[RelayCommand]
public async Task RemoveMemberAsync(CircleMemberDto? member)
{
if (member is null || SelectedCircle is null) return;
IsBusy = true;
try
{
await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id);
Members.Remove(member);
StatusMessage = $"« {member.UserName} » retiré du cercle";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -4,8 +4,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot; using PostIt.Models;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -25,7 +24,7 @@ public partial class MainPageViewModel : ViewModelBase
/// previous "{Binding SelectedPost.Title}" binding, the user's /// previous "{Binding SelectedPost.Title}" binding, the user's
/// keystrokes were silently dropped whenever /// keystrokes were silently dropped whenever
/// <c>SelectedPost was null</c>, which made the editor a trap /// <c>SelectedPost was null</c>, which made the editor a trap
/// and caused Save to POST a <c>BlogPostDto</c> with an empty /// and caused Save to POST a <c>BlogPost</c> with an empty
/// title — hence the 400 "The Title field is required".</summary> /// title — hence the 400 "The Title field is required".</summary>
[ObservableProperty] [ObservableProperty]
public partial string DraftTitle { get; set; } public partial string DraftTitle { get; set; }
@ -35,18 +34,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; }
@ -59,13 +46,13 @@ public partial class MainPageViewModel : ViewModelBase
public partial string SearchText { get; set; } public partial string SearchText { get; set; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<BlogPostDto> Posts { get; set; } public partial ObservableCollection<BlogPost> Posts { get; set; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; } public partial ObservableCollection<BlogPost> FilteredPosts { get; set; }
[ObservableProperty] [ObservableProperty]
public partial BlogPostDto? SelectedPost { get; set; } public partial BlogPost? SelectedPost { get; set; }
[ObservableProperty] [ObservableProperty]
public partial bool IsBusy { get; set; } public partial bool IsBusy { get; set; }
@ -95,8 +82,8 @@ public partial class MainPageViewModel : ViewModelBase
private void Init(Settings? settings) private void Init(Settings? settings)
{ {
SearchText = string.Empty; SearchText = string.Empty;
Posts = new ObservableCollection<BlogPostDto>(); Posts = new ObservableCollection<BlogPost>();
FilteredPosts = new ObservableCollection<BlogPostDto>(); FilteredPosts = new ObservableCollection<BlogPost>();
SelectedPost = null; SelectedPost = null;
IsBusy = false; IsBusy = false;
StatusMessage = "Ready"; StatusMessage = "Ready";
@ -114,7 +101,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;
} }
@ -133,7 +119,7 @@ public partial class MainPageViewModel : ViewModelBase
partial void OnSearchTextChanged(string value) => ApplyFilter(); partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedPostChanged(BlogPostDto? value) partial void OnSelectedPostChanged(BlogPost? value)
{ {
// Mirror the selection into the editor buffer so the // Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content // XAML-bound TextBox/TextEditor show the right content
@ -144,9 +130,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();
} }
@ -193,7 +176,7 @@ public partial class MainPageViewModel : ViewModelBase
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
// Build a fresh BlogPostDto from the editor buffer on // Build a fresh BlogPost from the editor buffer on
// every Save — we no longer mutate SelectedPost in // every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer // place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null) // (which was a no-op when SelectedPost was null)
@ -205,7 +188,7 @@ public partial class MainPageViewModel : ViewModelBase
// the update path. // the update path.
if (SelectedPost is null || SelectedPost.Id == 0) if (SelectedPost is null || SelectedPost.Id == 0)
{ {
var draft = new BlogPostDto var draft = new BlogPost
{ {
Title = DraftTitle, Title = DraftTitle,
Article = DraftArticle ?? string.Empty, Article = DraftArticle ?? string.Empty,
@ -221,7 +204,7 @@ public partial class MainPageViewModel : ViewModelBase
} }
else else
{ {
var update = new BlogPostDto var update = new BlogPost
{ {
Id = SelectedPost.Id, Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId, AuthorId = SelectedPost.AuthorId,
@ -257,46 +240,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()
{ {
@ -373,32 +316,4 @@ public partial class MainPageViewModel : ViewModelBase
/// forced the buggy "draft with empty title" branch.</summary> /// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
/// <summary>
/// Raised when the user asks to open the "manage ACL" dialog for
/// the currently selected post. The <c>MainPage</c> code-behind
/// listens to this event and pushes a <c>PostAclDialog</c> on the
/// navigation stack. The VM itself can't navigate directly
/// because the navigation surface (<c>NavigationPage</c>) lives
/// in the View layer.
/// </summary>
public event EventHandler<BlogPostDto>? ManageAclRequested;
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public void ManageAcl()
{
if (SelectedPost is null) return;
ManageAclRequested?.Invoke(this, SelectedPost);
}
/// <summary>
/// Raised when the user asks to open the circles page (full
/// CRUD on their own circles). Same routing as
/// <see cref="ManageAclRequested"/>.
/// </summary>
public event EventHandler? OpenCirclesRequested;
[RelayCommand]
public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty);
} }

View file

@ -1,157 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "Gérer l'ACL" modal of a single blog post.
///
/// <para>Loads the caller's circles once on construct (the dropdown
/// only shows circles the user owns), then keeps an in-memory list
/// of the ACL entries for the post. <see cref="AddAsync"/> /
/// <see cref="RevokeAsync"/> are the only mutating verbs; both
/// refresh the list afterwards so the UI stays in sync with the
/// server.</para>
///
/// <para>The server is the source of truth: it scopes every
/// endpoint to the caller's uid and rejects ACL grants on posts
/// the caller doesn't own. This VM does not re-validate that —
/// any 403 / 404 will surface as an exception caught by the
/// command and routed to <see cref="StatusMessage"/>.</para>
/// </summary>
public partial class PostAclDialogViewModel : ViewModelBase
{
private readonly BlogAclApiClient _aclClient;
private readonly CircleApiClient _circleClient;
/// <summary>The post whose ACL is being edited. Set by the
/// caller (MainPage) when opening the dialog.</summary>
public BlogPostDto Post { get; }
[ObservableProperty]
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new();
[ObservableProperty]
public partial CircleDto? SelectedCircleToAdd { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
public PostAclDialogViewModel(
BlogPostDto post,
BlogAclApiClient aclClient,
CircleApiClient circleClient)
{
Post = post ?? throw new ArgumentNullException(nameof(post));
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
}
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
[RelayCommand]
public async Task LoadAsync()
{
IsBusy = true;
try
{
// Load circles and ACL entries in parallel — both are
// independent reads on the same host. The caller's uid
// is implicit in both endpoints.
var circlesTask = _circleClient.GetMyCirclesAsync();
var aclTask = _aclClient.GetMyAclAsync();
await Task.WhenAll(circlesTask, aclTask);
var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles);
var allAcl = aclTask.Result ?? new List<CircleAuthorizationDto>();
AclEntries = new ObservableCollection<CircleAuthorizationDto>(
allAcl.Where(a => a.BlogPostId == Post.Id));
StatusMessage = $"{AclEntries.Count} autorisation(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task AddAsync()
{
if (SelectedCircleToAdd is null)
{
StatusMessage = "Sélectionnez un cercle à ajouter";
return;
}
IsBusy = true;
try
{
var created = await _aclClient.GrantAsync(new CircleAuthorizationDto
{
CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id,
Comment = false,
});
if (created is not null)
{
AclEntries.Add(created);
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
}
else
{
StatusMessage = "Autorisation refusée par le serveur";
}
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task RevokeAsync(CircleAuthorizationDto? acl)
{
if (acl is null) return;
IsBusy = true;
try
{
await _aclClient.RevokeAsync(acl.CircleId);
AclEntries.Remove(acl);
StatusMessage = "Autorisation révoquée";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,57 +0,0 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.AddCircleMemberDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel"
>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<!-- Search box + button -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsEnabled="{Binding !IsBusy}">
<TextBox Grid.Column="0"
Text="{Binding SearchQuery, Mode=TwoWay}"
PlaceholderText="Nom ou email d'un utilisateur Yavsc..."
HorizontalAlignment="Stretch"/>
<Button Grid.Column="1" Content="Rechercher"
Command="{Binding SearchCommand}"
Margin="8,0,0,0"/>
</Grid>
<!-- Selection hint -->
<TextBlock Grid.Row="1"
Text="Sélectionnez un résultat puis cliquez Ajouter."
FontSize="11" Opacity="0.6"
Margin="0,0,0,8"/>
<!-- Search results -->
<ListBox Grid.Row="2"
ItemsSource="{Binding Results}"
SelectedItem="{Binding Selected, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="services:UserSummary">
<StackPanel Spacing="2">
<TextBlock Text="{Binding DisplayName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" Margin="0,8,0,0">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Ajouter"
Command="{Binding AddCommand}"
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fermer"
Click="OnCloseClicked"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,54 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
/// <summary>
/// Modal "add a member to a circle" page. Hosted by
/// <c>CirclesPage</c>; the caller passes the resolved
/// <see cref="IUserDirectory"/> via the constructor.
///
/// <para>The dialog raises <c>Confirmed</c> on its ViewModel
/// when the user picks a result and clicks "Ajouter"; the
/// hosting page subscribes to that event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id. The dialog itself does not know the circle id
/// by design.</para>
/// </summary>
public partial class AddCircleMemberDialog : ContentPage
{
public AddCircleMemberDialog()
{
InitializeComponent();
}
public AddCircleMemberDialog(IUserDirectory directory)
{
InitializeComponent();
DataContext = new AddCircleMemberDialogViewModel(directory);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
/// <summary>
/// Subscribe a handler to be notified when the user
/// confirms a selection. Returns the underlying VM so
/// the caller can also drive further state (clear the
/// selection, close the dialog, refresh its own list).
/// </summary>
public AddCircleMemberDialogViewModel? ViewModel
=> DataContext as AddCircleMemberDialogViewModel;
private void OnCloseClicked(object? sender, RoutedEventArgs e)
{
// Same light-modal pattern as PostAclDialog: rely on
// the system back gesture or the navigation host's
// "pop" — the ContentPage doesn't own the back stack.
}
}

View file

@ -1,111 +0,0 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.CirclesPage"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:CirclesPageViewModel"
>
<Grid RowDefinitions="Auto,*,Auto">
<!-- Toolbar: refresh + new -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
<Button Content="Rafraîchir"
Command="{Binding RefreshCommand}"/>
<Button Content="Nouveau"
Command="{Binding StartCreateCommand}"/>
</StackPanel>
<!-- Two-pane body: circles (left) + members (right) -->
<Grid Grid.Row="1" Margin="12,0,12,12"
ColumnDefinitions="*,16,*"
RowDefinitions="*,Auto">
<!-- Left column: list of circles + editor -->
<Grid Grid.Row="0" Grid.Column="0"
RowDefinitions="*,Auto">
<ListBox Grid.Row="0"
ItemsSource="{Binding Circles}"
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
<TextBlock Text="{Binding Public, StringFormat='Public : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Éditer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).StartEditCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="2" Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).DeleteCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Editor -->
<Grid Grid.Row="1" Margin="0,12,0,0" RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding DraftName, Mode=TwoWay}"/>
<CheckBox Grid.Row="1" Grid.Column="1"
Content="Public"
IsChecked="{Binding DraftPublic, Mode=TwoWay}"/>
<Button Grid.Row="2" Grid.Column="1" Content="Enregistrer"
Command="{Binding SaveCommand}"
HorizontalAlignment="Right" Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Right column: members of the selected circle -->
<Grid Grid.Row="0" Grid.Column="2"
RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Membres"
FontWeight="Bold"
VerticalAlignment="Center"/>
<Button Content="Ajouter un membre"
Command="{Binding OpenAddMemberCommand}"/>
</StackPanel>
<ListBox Grid.Row="1"
ItemsSource="{Binding Members}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleMemberDto">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding FullName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Retirer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).RemoveMemberCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty-state hint -->
<TextBlock Grid.Row="2"
Text="Sélectionnez un cercle pour voir ses membres."
IsVisible="{Binding SelectedCircle, Converter={x:Static ObjectConverters.IsNull}}"
FontSize="11" Opacity="0.6"
Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Status bar -->
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,59 +0,0 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class CirclesPage : ContentPage
{
private CirclesPageViewModel? _vm;
public CirclesPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking
// handlers across navigation pushes / DataContext resets.
if (_vm is not null)
_vm.AddMemberRequested -= OnAddMemberRequested;
_vm = DataContext as CirclesPageViewModel;
if (_vm is not null)
_vm.AddMemberRequested += OnAddMemberRequested;
}
private void OnAddMemberRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || _vm is null) return;
// Resolve the directory via DI. The dialog raises its
// own Confirmed event; the VM subscribes via the method
// below — we pass the VM in so the closure can call
// back into it without the dialog needing to know the
// type of its caller. EventHandler<UserSummary> wants a
// void return, so wrap the async VM method in a fire-
// and-forget helper.
var directory = services.GetRequiredService<IUserDirectory>();
var dialog = new AddCircleMemberDialog(directory);
dialog.ViewModel!.Confirmed += async (sender, picked) =>
await _vm.OnAddMemberConfirmedAsync(sender, picked);
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:models="using:Yavsc.Blogspot" xmlns:models="using:PostIt.Models"
xmlns:views="using:PostIt.Views" xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d" mc:Ignorable="d"
@ -33,20 +33,6 @@
<Button Command="{Binding Search}" Content="Filter" /> <Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding Save}" Content="Save" /> <Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" /> <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 DEV ONLY: temporary shortcut to open the signature
capture page. Production entry point is a SignalR capture page. Production entry point is a SignalR
@ -65,7 +51,7 @@
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}" <ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPost">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" /> <TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />

View file

@ -1,11 +1,8 @@
using System;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
namespace PostIt.Views; namespace PostIt.Views;
@ -14,55 +11,6 @@ public partial class MainPage : ContentPage
public MainPage() public MainPage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
MainPageViewModel? _vm;
void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking handlers
// when DataContext is reassigned (e.g. by the navigation
// host or a binding reset).
if (_vm is not null)
{
_vm.ManageAclRequested -= OnManageAclRequested;
_vm.OpenCirclesRequested -= OnOpenCirclesRequested;
}
_vm = DataContext as MainPageViewModel;
if (_vm is not null)
{
_vm.ManageAclRequested += OnManageAclRequested;
_vm.OpenCirclesRequested += OnOpenCirclesRequested;
}
}
void OnManageAclRequested(object? sender, BlogPostDto post)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || post is null) return;
var dialog = new PostAclDialog(
post,
services.GetRequiredService<BlogAclApiClient>(),
services.GetRequiredService<CircleApiClient>());
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
}
void OnOpenCirclesRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<CirclesPage>();
page.DataContext = services.GetRequiredService<CirclesPageViewModel>();
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(page);
} }
/// <summary> /// <summary>

View file

@ -1,65 +0,0 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:PostAclDialogViewModel"
>
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
<!-- Add a new authorisation -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsEnabled="{Binding !IsBusy}">
<ComboBox Grid.Column="0"
ItemsSource="{Binding MyCircles}"
SelectedItem="{Binding SelectedCircleToAdd, Mode=TwoWay}"
PlaceholderText="Choisir un cercle..."
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Content="Ajouter"
Command="{Binding AddCommand}"
Margin="8,0,0,0"/>
</Grid>
<!-- Current ACL entries -->
<ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleAuthorizationDto">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/>
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Révoquer"
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons: close -->
<Button Grid.Row="2" Content="Fermer"
Click="OnCloseClicked"
HorizontalAlignment="Right"
Margin="0,8,0,8"/>
<!-- Status bar -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,54 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PostIt.ViewModels;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
namespace PostIt.Views;
/// <summary>
/// Modal "manage ACL" page for a single blog post.
///
/// <para>The ViewModel is constructed here (not via DI) because it
/// depends on the post being managed, which the caller (the post
/// list page) only knows at the moment it opens the dialog. The
/// DI container can build the two API clients; the post and the
/// VM are wired together here.</para>
/// </summary>
public partial class PostAclDialog : ContentPage
{
public PostAclDialog()
{
InitializeComponent();
}
public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient)
{
InitializeComponent();
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
// Pop this page off the navigation stack. Avalonia's
// NavigationPage doesn't have a typed "Close" — the
// hosting control (a NavigationPage in MainWindow.axaml)
// is the one that owns the back stack, but the
// ContentPage itself doesn't know about it. A simpler
// contract: fire an event the host listens to, or rely
// on the system back gesture. We do the latter — the
// dialog is intentionally modal-light.
if (this.VisualRoot is NavigationPage nav)
{
// The actual API varies between Avalonia 11.x
// versions; the safest call is the equivalent of
// "go back", which lives on the host. For now, hide
// the page and let the host decide.
}
}
}

View file

@ -1,48 +0,0 @@
using System;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Blogspot;
public class BlogPostDto : IBlogPost
{
public string AuthorId { get; set; }
public IApplicationUser Author { get; set; }
public string Article { get; set ; }
public string Photo { get; set ; }
public long Id { get; set; }
public DateTime DateCreated { get; set; }
public string UserCreated { get; set; }
public DateTime DateModified { get; set; }
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();
}
public ICircleAuthorization[] GetACL()
{
throw new NotImplementedException();
}
public string[] GetTags()
{
throw new NotImplementedException();
}
}

View file

@ -9,10 +9,10 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Library>true</Library> <Library>true</Library>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" /> <PackageReference Include="GitVersion.MsBuild" />

View file

@ -1,49 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
///
/// <para>Each <see cref="CircleAuthorizationDto"/> grants a single
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
/// scopes every endpoint to the caller's uid: only the author of
/// the underlying blog post can list, create, modify, or delete
/// its ACL entries.</para>
/// </summary>
public sealed class BlogAclApiClient
{
private const string Path = "blogacl";
private readonly IYavscApiClient _api;
public BlogAclApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
public Task<List<CircleAuthorizationDto>> GetMyAclAsync(CancellationToken ct = default)
=> _api.CallAsync<List<CircleAuthorizationDto>>(HttpMethod.Get, Path, ct: ct);
public Task<CircleAuthorizationDto?> GetAclAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
public Task<CircleAuthorizationDto?> GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Post, Path, body: acl, ct: ct);
public Task UpdateAclAsync(long circleId, CircleAuthorizationDto acl, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
public Task RevokeAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{circleId}", ct: ct);
}

View file

@ -1,85 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/circle</c> on the Yavsc Blogs server.
///
/// <para>Same conventions as <see cref="BlogApiClient"/>: all
/// transport is delegated to <see cref="YavscApiClient"/>; this
/// class only maps paths to DTOs.</para>
///
/// <para>The server now (since the BlogAcl fix on this branch)
/// scopes every read and write to the caller's uid. There is no
/// way for the client to read or modify another user's circles
/// — the route will return 404 (not 403) when the circle exists
/// but belongs to someone else, to avoid leaking its existence.</para>
/// </summary>
public sealed class CircleApiClient
{
private const string Path = "circle";
private readonly IYavscApiClient _api;
public CircleApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
public Task<List<CircleDto>> GetMyCirclesAsync(CancellationToken ct = default)
=> _api.CallAsync<List<CircleDto>>(HttpMethod.Get, Path, ct: ct);
public Task<CircleDto?> GetCircleAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<CircleDto?>(HttpMethod.Get, $"{Path}/{id}", ct: ct);
public Task<CircleDto?> CreateCircleAsync(CircleDto circle, CancellationToken ct = default)
=> _api.CallAsync<CircleDto?>(HttpMethod.Post, Path, body: circle, ct: ct);
public Task UpdateCircleAsync(long id, CircleDto circle, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{id}", body: circle, ct: ct);
public Task DeleteCircleAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
/// <summary>
/// Returns the members of one of the caller's circles.
/// Returns null when the circle does not exist or is not
/// owned by the caller (the server scopes the endpoint
/// with a 404 in either case to avoid leaking existence
/// — this client flattens that into a null result).
/// </summary>
public Task<List<CircleMemberDto>?> GetMembersAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<List<CircleMemberDto>?>(HttpMethod.Get, $"{Path}/{id}/members", ct: ct);
/// <summary>
/// Adds a Yavsc user (resolved client-side via
/// <c>/api/user-search</c>) to one of the caller's
/// circles. Returns null when the circle does not exist
/// or is not owned by the caller, or when the target
/// user does not exist. Throws on 409 (already a
/// member) — callers that want idempotent behaviour
/// can swallow the exception or dedupe beforehand.
/// </summary>
public Task AddMemberAsync(long id, string userId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Post, $"{Path}/{id}/members",
body: new { userId }, ct: ct);
/// <summary>
/// Removes a user from one of the caller's circles.
/// Returns null on success (the server returns 200 OK
/// with no body) or when the membership does not
/// exist — both treated as success by the caller.
/// </summary>
public Task RemoveMemberAsync(long id, string userId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}/members/{userId}", ct: ct);
}

View file

@ -1,19 +0,0 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/blogacl</c> and friends.
///
/// <para>The server-side
/// <c>Yavsc.Models.Access.CircleAuthorizationToBlogPost</c> EF entity
/// carries virtual navigation properties (<c>Target</c>,
/// <c>Allowed</c>) that pull in the full BlogPost and Circle graphs.
/// The client never needs them: when showing the ACL of a post, the
/// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary>
public sealed class CircleAuthorizationDto
{
public long CircleId { get; set; }
public long BlogPostId { get; set; }
public bool Comment { get; set; }
}

View file

@ -1,23 +0,0 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/circle</c> and friends.
///
/// <para>Field names match the JSON the server emits (camelCase via
/// the default <see cref="System.Text.Json"/> policy), so no
/// <c>[JsonPropertyName]</c> attributes are required.</para>
///
/// <para>Mirrors the server-side <c>Yavsc.Models.Relationship.Circle</c>
/// EF entity but stops short of the navigation properties
/// (<c>Owner</c>, <c>Members</c>) which depend on
/// <c>ApplicationUser</c> and other server-only types. The client
/// only ever needs the id, name, and owner of a circle to drive
/// the UI.</para>
/// </summary>
public sealed class CircleDto
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string OwnerId { get; set; } = string.Empty;
public bool Public { get; set; }
}

View file

@ -1,21 +0,0 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/circle/{id}/members</c>.
///
/// <para>Mirrors the server-side
/// <c>Yavsc.Blogs.Controllers.CircleMemberDto</c>. Intentionally
/// stops short of the Email field that
/// <see cref="UserSearchResultDto"/> carries — the circle
/// membership UI only needs a name and an avatar to render the
/// list. If the future ACL UI wants contact details, it can
/// fall back to <see cref="IYavscApiClient"/>'s other
/// endpoints rather than widening this shape.</para>
/// </summary>
public sealed class CircleMemberDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
}

View file

@ -1,23 +0,0 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/user-search</c>.
///
/// <para>Mirrors the server-side
/// <c>Yavsc.Blogs.Controllers.UserSearchResultDto</c> but stops
/// short of any entity navigation properties. Only the fields
/// a client address book needs (id, name, avatar, email) are
/// included.</para>
///
/// <para>Field names match the JSON the server emits (camelCase
/// via the default <see cref="System.Text.Json"/> policy), so
/// no <c>[JsonPropertyName]</c> attributes are required.</para>
/// </summary>
public sealed class UserSearchResultDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
public string? Email { get; set; }
}

View file

@ -1,62 +0,0 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// Transport surface that the high-level clients
/// (<see cref="BlogApiClient"/>, <see cref="CircleApiClient"/>,
/// <see cref="BlogAclApiClient"/>) need to do their work.
///
/// <para>This is intentionally a thin, transport-only contract. It
/// does not include the OIDC login / refresh / logout surface —
/// that lives on the concrete <c>YavscApiClient</c> in the
/// consuming application and is wired by the application
/// composition root. Splitting the two keeps <c>Yavsc.Api.Client</c>
/// usable from any host (a CLI, a unit test, a future iOS
/// client) without dragging OIDC, identity, and a <c>Settings</c>
/// POMVO everywhere.</para>
///
/// <para>Implementations are expected to:</para>
/// <list type="bullet">
/// <item>Attach a Bearer access token to every outbound request.</item>
/// <item>Silently refresh the token on a 401 and retry once.</item>
/// <item>Serialise the request body as JSON and deserialise the
/// response body with case-insensitive property matching.</item>
/// </list>
///
/// The exception contract on non-2xx responses is
/// <see cref="HttpRequestException"/> with a message that includes
/// the response body (capped), so callers can surface the
/// server-side validation problem to the UI without losing
/// context.
/// </summary>
public interface IYavscApiClient : IAsyncDisposable
{
/// <summary>
/// The configured <see cref="HttpClient"/>. Clients set its
/// <c>BaseAddress</c> in their constructors to point at the
/// API host they target.
/// </summary>
HttpClient Http { get; }
/// <summary>Call a JSON endpoint with a typed return value.</summary>
/// <param name="method">HTTP verb.</param>
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
/// <param name="body">Optional request body, serialised as JSON.</param>
/// <param name="ct">Cancellation token.</param>
Task<T> CallAsync<T>(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
/// <summary>Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).</summary>
Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
}

View file

@ -1,79 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/user-search</c> on the Yavsc Blogs
/// server. Used by client-side address books (PostIt.Desktop,
/// future PostIt.Browser CLI, …) to look up Yavsc users by
/// display name or email.
///
/// <para>The server scopes every endpoint to the authenticated
/// caller; any authenticated user can search the user table of
/// the instance. There is no per-user filtering on the response
/// side — this is by design on single-tenant deployments
/// (closed community). Multi-tenant deployments should gate
/// this controller behind a tenant-scoped policy before
/// exposing it; see the server-side
/// <c>UserSearchApiController</c> doc for details.</para>
/// </summary>
public sealed class UserSearchClient
{
private const string Path = "user-search";
private readonly IYavscApiClient _api;
public UserSearchClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
/// <summary>
/// Search users by display name (substring) or email (exact).
/// </summary>
/// <param name="query">Substring filter on FullName or
/// UserName. Empty or null returns an empty list (the server
/// would return all users, which we don't want by
/// default).</param>
/// <param name="email">Optional exact-match filter on
/// Email.</param>
/// <param name="take">Maximum results, capped at 100.
/// Default 25.</param>
public Task<List<UserSearchResultDto>> SearchAsync(
string? query = null,
string? email = null,
int take = 25,
CancellationToken ct = default)
{
// Match the server's contract: at least one filter is
// expected. The server doesn't enforce this (an empty
// query + empty email returns the first `take` users
// alphabetically), but the address-book UX is "type
// something to search", so we short-circuit empty
// queries client-side.
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(email))
return Task.FromResult(new List<UserSearchResultDto>());
var qs = new List<string>();
if (!string.IsNullOrWhiteSpace(query))
qs.Add($"q={Uri.EscapeDataString(query)}");
if (!string.IsNullOrWhiteSpace(email))
qs.Add($"e={Uri.EscapeDataString(email)}");
qs.Add($"take={Math.Clamp(take, 1, 100)}");
return _api.CallAsync<List<UserSearchResultDto>>(
HttpMethod.Get,
$"{Path}?{string.Join('&', qs)}",
ct: ct);
}
}

View file

@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Yavsc.Api.Client</RootNamespace>
<AssemblyName>Yavsc.Api.Client</AssemblyName>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Description>
Thin HTTP clients for the Yavsc API. Each client is a DTO↔path
mapper; all transport concerns (base URL, JSON, Bearer auth,
silent refresh on 401) are delegated to YavscApiClient, which
lives in the consuming application (PostIt).
</Description>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<Library>true</Library>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
</Project>

View file

@ -1,12 +1,12 @@
using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Access; using Yavsc.Models.Access;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/blogacl")] [Route("api/blogacl")]
@ -19,19 +19,11 @@ namespace Yavsc.Blogs.Controllers
_context = context; _context = context;
} }
/// <summary> // GET: api/BlogAclApi
/// Returns the ACL entries for the caller's own blog posts.
/// Blog posts (and therefore their ACLs) are private to their
/// author — the API never exposes another user's ACL.
/// </summary>
// GET: api/blogacl
[HttpGet] [HttpGet]
public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL() public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL()
{ {
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); return _context.CircleAuthorizationToBlogPost;
return _context.CircleAuthorizationToBlogPost
.Include(a => a.Allowed)
.Where(a => a.Allowed.OwnerId == uid);
} }
// GET: api/BlogAclApi/5 // GET: api/BlogAclApi/5

View file

@ -0,0 +1,146 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/cirle")]
public class CircleApiController : Controller
{
private readonly ApplicationDbContext _context;
public CircleApiController(ApplicationDbContext context)
{
_context = context;
}
// GET: api/CircleApi
[HttpGet]
public IEnumerable<Circle> GetCircle()
{
return _context.Circle;
}
// GET: api/CircleApi/5
[HttpGet("{id}", Name = "GetCircle")]
public async Task<IActionResult> GetCircle([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Circle circle = await _context.Circle.SingleAsync(m => m.Id == id);
if (circle == null)
{
return NotFound();
}
return Ok(circle);
}
// PUT: api/CircleApi/5
[HttpPut("{id}")]
public async Task<IActionResult> PutCircle([FromRoute] long id, [FromBody] Circle circle)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (id != circle.Id)
{
return BadRequest();
}
_context.Entry(circle).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateConcurrencyException)
{
if (!CircleExists(id))
{
return NotFound();
}
else
{
throw;
}
}
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
// POST: api/CircleApi
[HttpPost]
public async Task<IActionResult> PostCircle([FromBody] Circle circle)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
_context.Circle.Add(circle);
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateException)
{
if (CircleExists(circle.Id))
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
else
{
throw;
}
}
return CreatedAtRoute("GetCircle", new { id = circle.Id }, circle);
}
// DELETE: api/CircleApi/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteCircle([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Circle circle = await _context.Circle.SingleAsync(m => m.Id == id);
if (circle == null)
{
return NotFound();
}
_context.Circle.Remove(circle);
await _context.SaveChangesAsync(User.GetUserId());
return Ok(circle);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing);
}
private bool CircleExists(long id)
{
return _context.Circle.Count(e => e.Id == id) > 0;
}
}
}

View file

@ -5,10 +5,10 @@
<UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId> <UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId>
<RootNamespace>Yavsc.Api</RootNamespace> <RootNamespace>Yavsc.Api</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -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();

View file

@ -1,199 +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.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for the circle-members endpoints on
/// <c>CircleApiController</c>:
/// <c>GET /api/circle/{id}/members</c>,
/// <c>POST /api/circle/{id}/members</c>,
/// <c>DELETE /api/circle/{id}/members/{userId}</c>.
///
/// <para>Same fixture as <see cref="BlogApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides an in-memory
/// <c>ApplicationDbContext</c>, JWT bearer auth with HS256,
/// and the production <c>BlogScope</c> policy. Tests use
/// <c>TestTokenIssuer</c> to mint tokens whose <c>sub</c>
/// claim identifies the caller.</para>
///
/// <para>Test users (<c>alice</c>, <c>bob</c>) are seeded
/// directly via <see cref="ApplicationDbContext.Users"/>:
/// the Blogs fixture doesn't stand up
/// <c>UserManager&lt;ApplicationUser&gt;</c>, so we go
/// through the DbContext the same way the production code
/// would.</para>
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public CircleMembersApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database and seed
/// <c>alice</c> + <c>bob</c>. <c>UseInMemoryDatabase</c>
/// shares its store across the fixture lifetime, so each
/// test starts from a clean slate.</summary>
private void ResetDatabaseWithUsers()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.com",
EmailConfirmed = true,
FullName = "Bob Martin",
Avatar = "/avatars/bob.png",
});
db.SaveChanges();
}
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the in-memory store and return its server-assigned
/// id. The tests below use this to bypass the controller's POST
/// (which is already covered by other tests on the branch);
/// the focus here is the members endpoints.</summary>
private long SeedCircle(string ownerId, string name)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
private string MembersUrl(long circleId)
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/circle/{circleId}/members";
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 GetMembers_returns_200_with_empty_list_when_no_members()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var response = await http.GetAsync(MembersUrl(circleId));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task PostMember_returns_201_then_Get_returns_the_member()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var postResponse = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId));
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
var member = doc.RootElement[0];
Assert.Equal("bob", member.GetProperty("id").GetString());
Assert.Equal("bob", member.GetProperty("userName").GetString());
Assert.Equal("Bob Martin", member.GetProperty("fullName").GetString());
}
[Fact]
public async Task PostMember_returns_409_when_user_already_in_circle()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
var first = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
var second = await http.PostAsJsonAsync(
MembersUrl(circleId),
new { userId = "bob" });
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
[Fact]
public async Task DeleteMember_returns_200_then_Get_does_not_include_member()
{
ResetDatabaseWithUsers();
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("alice");
await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" });
var deleteResponse = await http.DeleteAsync(
$"{MembersUrl(circleId)}/bob");
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var getResponse = await http.GetAsync(MembersUrl(circleId));
using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public async Task GetMembers_returns_404_when_circle_not_owned_by_caller()
{
ResetDatabaseWithUsers();
// Alice's circle, Bob tries to read its members.
var circleId = SeedCircle("alice", "Famille");
using var http = NewClient("bob");
var response = await http.GetAsync(MembersUrl(circleId));
// 404, not 403 — the controller deliberately avoids leaking
// the existence of someone else's circle.
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -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);
}
}

View file

@ -7,10 +7,10 @@
<RootNamespace>Yavsc.Blogs.Tests</RootNamespace> <RootNamespace>Yavsc.Blogs.Tests</RootNamespace>
<UserSecretsId>b1a9d0d6-3f5e-4a07-9f0a-7e4d5b6c1a82</UserSecretsId> <UserSecretsId>b1a9d0d6-3f5e-4a07-9f0a-7e4d5b6c1a82</UserSecretsId>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />

View file

@ -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; }
}
} }

View file

@ -1,349 +0,0 @@
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers;
namespace Yavsc.Blogs.Controllers
{
[Produces("application/json")]
[Route("api/circle")]
public class CircleApiController : Controller
{
private readonly ApplicationDbContext _context;
public CircleApiController(ApplicationDbContext context)
{
_context = context;
}
/// <summary>
/// Returns the caller's own circles. Circles are personal —
/// the API never exposes another user's circles, even by id.
/// </summary>
// GET: api/circle
[HttpGet]
public IEnumerable<Circle> GetCircle()
{
var uid = User.GetUserId();
return _context.Circle.Where(c => c.OwnerId == uid);
}
/// <summary>
/// Returns a single circle only when it belongs to the caller.
/// </summary>
// GET: api/circle/5
[HttpGet("{id}", Name = "GetCircle")]
public async Task<IActionResult> GetCircle([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
Circle circle = await _context.Circle.SingleOrDefaultAsync(
m => m.Id == id && m.OwnerId == uid);
if (circle == null)
{
return NotFound();
}
return Ok(circle);
}
/// <summary>
/// Replaces a circle. The caller must own it; the server
/// reasserts ownership regardless of any OwnerId the client
/// tries to put in the body.
/// </summary>
// PUT: api/circle/5
[HttpPut("{id}")]
public async Task<IActionResult> PutCircle([FromRoute] long id, [FromBody] Circle circle)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (id != circle.Id)
{
return BadRequest();
}
var uid = User.GetUserId();
var existing = await _context.Circle.SingleOrDefaultAsync(
c => c.Id == id && c.OwnerId == uid);
if (existing is null)
{
return new ChallengeResult();
}
// Force OwnerId to the caller; the body value is ignored.
circle.OwnerId = uid;
_context.Entry(circle).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateConcurrencyException)
{
if (!CircleExists(id))
{
return NotFound();
}
else
{
throw;
}
}
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
/// <summary>
/// Creates a circle owned by the caller. The server overwrites
/// any OwnerId the client sends in the body.
/// </summary>
// POST: api/circle
[HttpPost]
public async Task<IActionResult> PostCircle([FromBody] Circle circle)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
circle.OwnerId = uid;
_context.Circle.Add(circle);
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateException)
{
if (CircleExists(circle.Id))
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
else
{
throw;
}
}
return CreatedAtRoute("GetCircle", new { id = circle.Id }, circle);
}
/// <summary>
/// Deletes a circle only if the caller owns it. Returns 404
/// (not 403) when the circle does not exist or is not owned
/// by the caller, to avoid leaking the existence of someone
/// else's circle.
/// </summary>
// DELETE: api/circle/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteCircle([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
Circle circle = await _context.Circle.SingleOrDefaultAsync(
m => m.Id == id && m.OwnerId == uid);
if (circle == null)
{
return NotFound();
}
_context.Circle.Remove(circle);
await _context.SaveChangesAsync(User.GetUserId());
return Ok(circle);
}
/// <summary>
/// Returns the members of one of the caller's circles.
/// Returns 404 (not 403) when the circle does not exist
/// or is not owned by the caller, mirroring the scoping
/// of the rest of this controller.
/// </summary>
// GET: api/circle/5/members
[HttpGet("{id}/members")]
public async Task<IActionResult> GetMembers([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
if (!ownsIt)
{
return NotFound();
}
var members = await _context.CircleMembers
.Where(m => m.CircleId == id)
.Select(m => new CircleMemberDto
{
Id = m.MemberId,
UserName = m.Member.UserName ?? string.Empty,
FullName = m.Member.FullName,
Avatar = m.Member.Avatar,
})
.ToListAsync();
return Ok(members);
}
/// <summary>
/// Adds a Yavsc user to one of the caller's circles. The
/// body carries the user id (resolved client-side via the
/// central <c>/api/user-search</c> endpoint). Returns
/// 404 (not 403) when the circle does not exist or is not
/// owned by the caller, and 404 when the target user does
/// not exist, so the caller can't probe whether an email
/// belongs to a real account.
///
/// <para>Returns 409 Conflict if the user is already a
/// member of the circle; the client treats this as a
/// no-op success.</para>
/// </summary>
// POST: api/circle/5/members
// body: { "userId": "..." }
[HttpPost("{id}/members")]
public async Task<IActionResult> AddMember(
[FromRoute] long id,
[FromBody] AddCircleMemberDto body)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
if (!ownsIt)
{
return NotFound();
}
// Reject unknown user ids the same way as an unknown
// circle: 404. Probing the user table by id should not
// be possible through this endpoint.
var userExists = await _context.Users.AnyAsync(u => u.Id == body.UserId);
if (!userExists)
{
return NotFound();
}
// Idempotency: re-adding an existing member is a
// 409, not a silent success. Clients that don't
// dedupe beforehand will at least get an actionable
// status code rather than a misleading "created".
var alreadyMember = await _context.CircleMembers.AnyAsync(
m => m.CircleId == id && m.MemberId == body.UserId);
if (alreadyMember)
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
_context.CircleMembers.Add(new CircleMember
{
CircleId = id,
MemberId = body.UserId,
});
await _context.SaveChangesAsync(User.GetUserId());
return CreatedAtRoute("GetCircle", new { id }, body);
}
/// <summary>
/// Removes a user from one of the caller's circles.
/// Returns 404 when the circle does not exist or is not
/// owned by the caller, mirroring the rest of this
/// controller's scoping. Returns 404 when the user is
/// not a member of the circle (idempotent: removing a
/// non-member is the same as having nothing to remove).
/// </summary>
// DELETE: api/circle/5/members/tester
[HttpDelete("{id}/members/{userId}")]
public async Task<IActionResult> RemoveMember(
[FromRoute] long id,
[FromRoute] string userId)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
if (!ownsIt)
{
return NotFound();
}
var membership = await _context.CircleMembers.SingleOrDefaultAsync(
m => m.CircleId == id && m.MemberId == userId);
if (membership is null)
{
return NotFound();
}
_context.CircleMembers.Remove(membership);
await _context.SaveChangesAsync(User.GetUserId());
return Ok();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing);
}
private bool CircleExists(long id)
{
return _context.Circle.Count(e => e.Id == id) > 0;
}
}
/// <summary>
/// Wire shape for <c>GET /api/circle/{id}/members</c>.
/// Mirrors <see cref="UserSearchResultDto"/> but stops
/// short of the Email field — circle membership UI only
/// needs to render a name and an avatar, not contact
/// details.
/// </summary>
public sealed class CircleMemberDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
}
/// <summary>
/// Wire shape for <c>POST /api/circle/{id}/members</c>.
/// The body is intentionally tiny: the client resolves
/// the user id via <c>/api/user-search</c> before
/// posting, so all we need is the resolved id.
/// </summary>
public sealed class AddCircleMemberDto
{
public string UserId { get; set; } = string.Empty;
}
}

View file

@ -1,111 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
namespace Yavsc.Blogs.Controllers
{
/// <summary>
/// Central user search endpoint used by client address books
/// (PostIt.Desktop, future PostIt.Browser CLI, etc.).
///
/// <para>Live in <c>Yavsc.Blogs</c> rather than <c>Yavsc.Api</c>
/// because Yavsc.Api is not yet enabled in production; future
/// migration is mechanical (the namespace and route prefix are
/// the only ties to the host project).</para>
///
/// <para>Authorisation: any authenticated caller can search.
/// Results include <c>Email</c> on a best-effort basis —
/// the field is included because the address-book use case
/// (composing a circle membership, sending an invite) needs
/// it. The data set is the entire user table of the
/// instance, which on Yavsc's single-tenant deployments is
/// a closed community where users already know each other.
/// Multi-tenant deployments should gate this controller
/// behind a tenant-scoped authorisation policy before
/// exposing it.</para>
/// </summary>
[Produces("application/json")]
[Route("api/user-search")]
[Authorize]
public class UserSearchApiController : Controller
{
private readonly ApplicationDbContext _context;
public UserSearchApiController(ApplicationDbContext context)
{
_context = context;
}
/// <summary>
/// Search users by display name and/or email.
/// </summary>
/// <param name="q">Substring filter on
/// <see cref="ApplicationUser.FullName"/> or
/// <see cref="ApplicationUser.UserName"/> (case-insensitive,
/// contains). Optional.</param>
/// <param name="e">Exact filter on
/// <see cref="ApplicationUser.Email"/> (case-insensitive
/// equality). Optional.</param>
/// <param name="take">Maximum number of results, capped at
/// 100. Default 25.</param>
// GET: api/user-search?q=foo&e=bar@example.com&take=25
[HttpGet]
public async Task<IEnumerable<UserSearchResultDto>> SearchAsync(
[FromQuery] string? q = null,
[FromQuery] string? e = null,
[FromQuery] int take = 25)
{
take = Math.Clamp(take, 1, 100);
IQueryable<ApplicationUser> query = _context.Users;
if (!string.IsNullOrWhiteSpace(e))
{
// Email is treated as an exact match — most address
// book callers already know the email they're
// searching for and we don't want to surface a
// long tail of partial matches.
var normalised = e.Trim();
query = query.Where(u => u.Email != null && u.Email.ToLower() == normalised.ToLower());
}
if (!string.IsNullOrWhiteSpace(q))
{
var needle = q.Trim();
query = query.Where(u =>
(u.FullName != null && u.FullName.ToLower().Contains(needle.ToLower())) ||
(u.UserName != null && u.UserName.ToLower().Contains(needle.ToLower())));
}
var results = await query
.OrderBy(u => u.FullName ?? u.UserName)
.Take(take)
.Select(u => new UserSearchResultDto
{
Id = u.Id,
UserName = u.UserName ?? string.Empty,
FullName = u.FullName,
Avatar = u.Avatar,
Email = u.Email,
})
.ToListAsync();
return results;
}
}
/// <summary>
/// Search-result shape. Flat DTO with no navigation
/// properties so the JSON stays small even if the user
/// table grows.
/// </summary>
public sealed class UserSearchResultDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
public string? Email { get; set; }
}
}

View file

@ -6,10 +6,10 @@
<RootNamespace>Yavsc.Blogs</RootNamespace> <RootNamespace>Yavsc.Blogs</RootNamespace>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -9,10 +9,10 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<OutputType>exe</OutputType> <OutputType>exe</OutputType>
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath> <RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />

View file

@ -7,10 +7,10 @@
<RootNamespace>Yavsc</RootNamespace> <RootNamespace>Yavsc</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="HigginsSoft.IdentityServer8" /> <PackageReference Include="HigginsSoft.IdentityServer8" />

View file

@ -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; }

View file

@ -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;
} }
} }

View file

@ -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;
}
} }

View file

@ -7,10 +7,10 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Anthropic.SDK" /> <PackageReference Include="Anthropic.SDK" />

View file

@ -12,10 +12,6 @@
TestSdk. The consumer projects own the test execution and TestSdk. The consumer projects own the test execution and
inherit from the shared base classes. inherit from the shared base classes.
--> -->
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Hosting" /> <PackageReference Include="Microsoft.AspNetCore.Hosting" />

View file

@ -5,10 +5,10 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Yavsc.cli</RootNamespace> <RootNamespace>Yavsc.cli</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Razor" /> <PackageReference Include="Microsoft.AspNetCore.Razor" />