feat(blog): add Visibility { Private, Public } to gate post reads

Replace the implicit 'ACL empty = private' convention with an
explicit two-axis model: Visibility is the master switch, the
ACL is the exception list.

Semantics (matches what BlogSpotService.Index / Details enforce):

  Visibility.Public  + empty ACL  : every caller sees it
  Visibility.Public  + non-empty  : only author + ACL circles + admin
  Visibility.Private + any ACL    : only author + admin (ACL ignored)
                                    ACL is preserved across
                                    Private/Public flips so
                                    reopening is lossless

The Public+non-empty shape is the 'restrict by exception' case:
open by default, narrowed by the ACL. This is intentionally
different from the previous behaviour, where a Public post
with a non-empty ACL was effectively ACL-restricted anyway —
the new model makes that explicit and removes ambiguity.

Server (Yavsc.Blogs / Yavsc.Server)
- New enum Visibility { Private, Public } in
  Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity
  share the same type). Stored as int via .HasConversion<int>()
  on BlogPost.Visibility. Default Private on construction;
  the column default in the migration is 0 so existing rows
  land Private without any data migration.
- BlogSpotService.Index: filter rewritten to honour the two-
  axis model. Authenticated and anonymous callers now share
  the same shape (Public+emptyACL visible to all, otherwise
  scoped). Admin reads still go through PermissionHandler.
- PermissionHandler.IsPublic: dropped the blogSpotPublications
  lookup, replaced with the Visibility + empty-ACL check that
  matches the new model. PermissionHandler.IsSponsor and
  IsOwner unchanged.
- UserHelpers.UserPosts (the per-author feed for
  /CircleMembers/Details and similar): mirror of the
  Index filter, so the two code paths can't silently diverge.
- BlogPostEditViewModel.Publish untouched on this commit. It
  still controls whether a row exists in BlogSpotPublication;
  the two systems coexist (Publish = 'is this draft published',
  Visibility = 'who can read it'). Follow-up to consolidate.

EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility)
- Scaffolded by 'dotnet ef migrations add', not hand-edited,
  per the repo preference for generated migrations.
- Adds the new Visibility column (int, NOT NULL, default 0).
- Also drops three shadow-state ClientId1 foreign keys and
  their indexes/columns on ClientScopes, ClientRedirectUris,
  ClientGrantTypes. These shadow FKs were created by EF from
  HasOne<Client>().HasForeignKey(e => e.ClientId) mappings
  that have long since been removed from
  ApplicationDbContext.OnModelCreating, but the snapshot was
  never regenerated against the current model. The columns
  are nullable ints with no production data, so the drop is
  lossless. Without this, EF Core would keep emitting
  warnings on every migration add and the model would drift
  further from reality.

DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- Visibility property added to BlogPostDto. System.Text.Json
  serialises the enum as its underlying int, so the JSON
  shape is a plain number, no JsonConverter needed.

Client UI (PostIt)
- MainPageViewModel: DraftVisibility ObservableProperty
  mirroring the existing DraftTitle/DraftArticle pattern.
  Initialised to Private so a fresh draft is private by
  default. Save command writes the chosen value into the
  BlogPostDto payload for both CreatePostAsync and
  UpdatePostAsync. OnSelectedPostChanged hydrates the buffer
  from the server-supplied value.
- AllVisibilities property on the VM exposes [Private, Public]
  in that order, bound by the ComboBox in MainPage.axaml.
- VisibilityLabelConverter (PostIt.Views) maps the enum to
  French user-facing labels ('Privé' / 'Public'); registered
  in App.axaml as a static resource.
- MainPage.axaml: a new ComboBox row in the editor pane
  between Title and Article. Uses the existing 'no hardcoded
  Background without Foreground' lesson so dark mode works.

Tests (Yavsc.Blogs.Tests)
- BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with
  Visibility fixtures seeded directly in the in-memory DB:
    * Private + ACL: only the author sees it
    * Public + empty ACL: any authenticated caller sees it
    * Public + non-empty ACL: caller without ACL membership
      does NOT see it
    * Private + ACL: ACL is ignored, only the author sees it
    * Visibility round-trips through the JSON wire (int 1)
- UserHelpersVisibilityTests (4 [Fact]): exercise the helper
  directly so the two code paths (Index filter vs per-author
  feed) can't diverge silently. Same fixture, no HTTP.

Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility
+4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Visibilité :' label is localised; the
  rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ Visibility consolidation
  (which system wins when both are set on the same post?).
- Org-side UI for editing Visibility (the admin web Yavsc
  still edits posts without a visibility field).
This commit is contained in:
Paul Schneider 2026-08-18 15:35:22 +01:00
commit 33ecfa7ebd
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
16 changed files with 5427 additions and 46 deletions

View file

@ -1,11 +1,21 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PostIt"
xmlns:views="using:PostIt.Views"
x:Class="PostIt.App">
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
</Application.DataTemplates>
<Application.Resources>
<!-- French labels for the Visibility enum. Used by the
ComboBox ItemTemplate in MainPage.axaml. The list
of available values lives on MainPageViewModel
(AllVisibilities) rather than here — x:Array is
awkward to author in Avalonia XAML. -->
<views:VisibilityLabelConverter x:Key="VisibilityLabelConverter"/>
</Application.Resources>
<Application.Styles>
<FluentTheme />

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
@ -35,6 +36,25 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
public partial string DraftArticle { get; set; }
/// <summary>Editor buffer for the post visibility. Same
/// pattern as <see cref="DraftTitle"/> and
/// <see cref="DraftArticle"/>: the Save command reads from
/// here so the user can flip a draft to Public without
/// selecting an existing post first. Defaults to
/// <see cref="Visibility.Private"/> on a fresh draft so
/// new posts are private-by-default, matching the server
/// contract.</summary>
[ObservableProperty]
public partial Visibility DraftVisibility { get; set; } = Visibility.Private;
/// <summary>List of values offered in the visibility
/// ComboBox. Exposed as a VM property (rather than an
/// <c>x:Array</c> resource) because Avalonia XAML doesn't
/// author <c>x:Array</c> cleanly. Order: Private first,
/// matching the server default.</summary>
public IReadOnlyList<Visibility> AllVisibilities { get; } =
new[] { Visibility.Private, Visibility.Public };
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
@ -102,6 +122,7 @@ public partial class MainPageViewModel : ViewModelBase
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftVisibility = Visibility.Private;
CurrentViewModel = this;
}
@ -131,6 +152,9 @@ public partial class MainPageViewModel : ViewModelBase
// doesn't show stale content.
DraftTitle = value?.Title ?? string.Empty;
DraftArticle = value?.Article ?? string.Empty;
// Mirror visibility too. Defaults to Private on null
// selection so a fresh draft starts private.
DraftVisibility = value?.Visibility ?? Visibility.Private;
UpdateCommandStates();
}
@ -195,6 +219,7 @@ public partial class MainPageViewModel : ViewModelBase
Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
Visibility = DraftVisibility,
};
var created = await BlogClient.CreatePostAsync(draft);
if (created is not null)
@ -214,6 +239,7 @@ public partial class MainPageViewModel : ViewModelBase
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
Visibility = DraftVisibility,
};
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";

View file

@ -81,7 +81,26 @@
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<AvaloniaEdit:TextEditor Grid.Row="2"
<!-- Visibility toggle: a ComboBox bound to the
Yavsc.Blogspot.Visibility enum (Private /
Public). The enum serialises as int on the
wire; user-facing labels come from the
visibility label converter registered in
App.axaml. Default is Visibility.Private
(set on DraftVisibility), so a fresh draft
is private by default. -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
<TextBlock Text="Visibilité :" VerticalAlignment="Center"/>
<ComboBox SelectedItem="{Binding DraftVisibility, Mode=TwoWay}"
ItemsSource="{Binding AllVisibilities}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Visibility">
<TextBlock Text="{Binding Converter={StaticResource VisibilityLabelConverter}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<AvaloniaEdit:TextEditor Grid.Row="3"
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
@ -90,7 +109,7 @@
VerticalAlignment="Stretch"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" />
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
<TextBlock Grid.Row="4" Text="{Binding StatusMessage}" Foreground="Gray" />
</Grid>
</Border>
</Grid>

View file

@ -0,0 +1,56 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Yavsc.Blogspot;
namespace PostIt.Views;
/// <summary>
/// Converts a <see cref="Visibility"/> enum value to a user-
/// facing French label. Used by <c>MainPage.axaml</c> to render
/// the visibility ComboBox without exposing the raw enum name
/// ("Private" / "Public") to the end user.
///
/// <para>Bidirectional: <c>ConvertBack</c> returns the value
/// unchanged, so the ComboBox can drive the bound
/// <c>DraftVisibility</c> property directly through the same
/// converter — the ComboBox just happens to use
/// <c>SelectedItem</c> binding so ConvertBack is never
/// invoked. The symmetry is kept for completeness in case a
/// future XAML needs to bind via <c>Text</c>.</para>
/// </summary>
public sealed class VisibilityLabelConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is Visibility v)
{
return v switch
{
Visibility.Private => "Privé",
Visibility.Public => "Public",
_ => v.ToString(),
};
}
return value;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
// Reverse mapping: user input is unlikely to be the
// raw English enum name (the ComboBox shows French
// labels), so ConvertBack falls back to Private on any
// unrecognised input. The ComboBox uses SelectedItem
// binding so this path is never actually taken today.
if (value is string s)
{
return s switch
{
"Privé" => Visibility.Private,
"Public" => Visibility.Public,
_ => Visibility.Private,
};
}
return value;
}
}