From 251f2d818b9c7075109690c7b85b0f4b741af4f6 Mon Sep 17 00:00:00 2001 From: tleininger Date: Tue, 22 Sep 2026 19:32:32 +0200 Subject: [PATCH 1/4] Add PocketBase client with contracts and sharp build tooling --- .editorconfig | 56 +++++++++ .env.example | 7 ++ .gitignore | 3 + Directory.Build.props | 20 ++++ Directory.Packages.props | 25 ++++ .../Elternbeirat.Contracts.csproj | 9 ++ Elternbeirat.Contracts/Event.cs | 39 ++++++ Elternbeirat.Contracts/Faq.cs | 29 +++++ Elternbeirat.Contracts/Page.cs | 47 ++++++++ Elternbeirat.Contracts/Post.cs | 34 ++++++ .../Elternbeirat.PocketBase.csproj | 13 ++ .../LocalDateTimeConverter.cs | 75 ++++++++++++ Elternbeirat.PocketBase/PocketBaseClient.cs | 59 ++++++++++ Elternbeirat.PocketBase/RecordList.cs | 15 +++ Elternbeirat.Web.Tests/.editorconfig | 8 ++ .../Elternbeirat.Web.Tests.csproj | 13 +- .../PocketBaseClientTests.cs | 111 ++++++++++++++++++ Elternbeirat.Web.Tests/RouteSmokeTests.cs | 8 +- Elternbeirat.Web/Elternbeirat.Web.csproj | 4 +- Elternbeirat.Web/Features/Events/Event.cs | 2 +- .../Features/Events/EventService.cs | 3 +- .../Features/Events/IcsCalendar.cs | 15 +-- Elternbeirat.Web/Program.cs | 1 + Elternbeirat.slnx | 2 + 24 files changed, 578 insertions(+), 20 deletions(-) create mode 100644 .editorconfig create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 Elternbeirat.Contracts/Elternbeirat.Contracts.csproj create mode 100644 Elternbeirat.Contracts/Event.cs create mode 100644 Elternbeirat.Contracts/Faq.cs create mode 100644 Elternbeirat.Contracts/Page.cs create mode 100644 Elternbeirat.Contracts/Post.cs create mode 100644 Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj create mode 100644 Elternbeirat.PocketBase/LocalDateTimeConverter.cs create mode 100644 Elternbeirat.PocketBase/PocketBaseClient.cs create mode 100644 Elternbeirat.PocketBase/RecordList.cs create mode 100644 Elternbeirat.Web.Tests/.editorconfig create mode 100644 Elternbeirat.Web.Tests/PocketBaseClientTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..74d6a2f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,56 @@ +# EditorConfig for the Elternbeirat solution. +# https://editorconfig.org / https://learn.microsoft.com/dotnet/fundamentals/code-analysis/code-style-rule-options +# +# Code-style rules are added here over time. Kept intentionally minimal for now; +# the .NET analyzers (see Directory.Build.props) already run at their sharpest. + +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{cs,csproj,props,targets}] +indent_size = 4 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.cs] +# CA1716 flags type names that collide with Visual Basic keywords (e.g. Event). +# This app is not consumed from VB, and Event/Post are the project's deliberate +# domain names (see CLAUDE.md: "im Code Event, nicht Termin"). Renaming would +# break that convention for a problem this codebase does not have. +dotnet_diagnostic.CA1716.severity = none + +# CA1515 suggests making types internal because an app's types are not referenced +# from outside its assembly. That is a library-author rule with no benefit here: +# this is an application, not a reusable package. Razor components (Home, PostList, +# ...) must stay public so Blazor can render them, and the Contracts records are +# consumed from another project. Turning types internal would gain nothing. +dotnet_diagnostic.CA1515.severity = none + +# CA1062 wants public methods to null-check their arguments. The public surface +# here is DI constructors and Blazor components, never called by foreign code with +# raw arguments; the container always supplies its dependencies. With #nullable on, +# a non-nullable parameter already carries the "never null" contract in its type. +dotnet_diagnostic.CA1062.severity = none + +# CA2007 (ConfigureAwait) targets libraries with a SynchronizationContext (WinForms, +# WPF, classic ASP.NET). ASP.NET Core has none, so ConfigureAwait(false) would be +# pure noise here. Microsoft's own project templates disable this rule. +dotnet_diagnostic.CA2007.severity = none + +# CA1812 flags types "never instantiated". The JSON DTOs (e.g. RecordList) are +# only created by the deserializer via reflection, which the analyzer cannot see. +# This is a known false positive for deserialization types. +dotnet_diagnostic.CA1812.severity = none + +# CA1724 flags a type name that matches part of its namespace (Home in +# ...Features.Home). That collision is a deliberate result of the feature-folder +# layout and is harmless (Blazor routing is not namespace-based). Renaming would +# break the folder convention for no benefit. +dotnet_diagnostic.CA1724.severity = none diff --git a/.env.example b/.env.example index bcd4377..80339d9 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,10 @@ GITEA_TOKEN= # Base URL of the Gitea instance and the repo path issues belong to. GITEA_URL=https://gitea.anticarnist.de GITEA_REPO=Tom/Elternbeirat + +# PocketBase superuser login, used to read/write records via the REST API +# (e.g. migrating content) without loosening the collection API rules -- a +# superuser bypasses them. Create the superuser in the dashboard at /_/. +PB_URL=http://:8090 +PB_ADMIN_EMAIL= +PB_ADMIN_PASSWORD= diff --git a/.gitignore b/.gitignore index 1e06d8f..daf55a7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ obj/ riderModule.iml /_ReSharper.Caches/ .idea/ +# Per-user Rider/ReSharper settings (personal, not shared). +*.sln.DotSettings.user +*.DotSettings.user # Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit # personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0ed0f70 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + + true + All + latest-all + + + true + + + true + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..3dbf02a --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,25 @@ + + + + + true + + + + + + + + + + + + + + + + + + diff --git a/Elternbeirat.Contracts/Elternbeirat.Contracts.csproj b/Elternbeirat.Contracts/Elternbeirat.Contracts.csproj new file mode 100644 index 0000000..c79a88d --- /dev/null +++ b/Elternbeirat.Contracts/Elternbeirat.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs new file mode 100644 index 0000000..22201ef --- /dev/null +++ b/Elternbeirat.Contracts/Event.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// A calendar entry. Sorted by . +public record Event +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// + /// Start of the event with date and time. Stored as UTC by PocketBase but + /// read as local time (Europe/Berlin) by convention. An all-day event uses + /// 00:00 as the time. + /// + [JsonPropertyName("start")] + public DateTime Start { get; init; } + + /// Optional end of the event; null when unset. + [JsonPropertyName("end")] + public DateTime? End { get; init; } + + /// Event name, e.g. "Elternbeiratssitzung". + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + /// Optional location, e.g. "Aula". + [JsonPropertyName("location")] + public string Location { get; init; } = ""; + + /// Optional note, e.g. "Anmeldung erforderlich". + [JsonPropertyName("note")] + public string Note { get; init; } = ""; + + /// Whether the event is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs new file mode 100644 index 0000000..70ac2a2 --- /dev/null +++ b/Elternbeirat.Contracts/Faq.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// +/// A single question and answer, grouped on the FAQ page by . +/// +public record Faq +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// The question as a parent would phrase it. + [JsonPropertyName("question")] + public string Question { get; init; } = ""; + + /// The answer in Markdown. + [JsonPropertyName("answer")] + public string Answer { get; init; } = ""; + + /// Topic the question is grouped under, e.g. "mensa". + [JsonPropertyName("topic")] + public string Topic { get; init; } = ""; + + /// Whether the question is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs new file mode 100644 index 0000000..4c1ac14 --- /dev/null +++ b/Elternbeirat.Contracts/Page.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// +/// A content page. Pages also drive the site navigation: +/// and decide where and in which order a page appears in the +/// header or footer menu, and lists dynamic blocks (posts, +/// events, faqs) rendered below the page body. +/// +public record Page +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// Heading shown to visitors; may contain umlauts and spaces. + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + /// Page body in Markdown. + [JsonPropertyName("body")] + public string Body { get; init; } = ""; + + /// Where the page appears in the navigation: "header" or "footer". + [JsonPropertyName("location")] + public string Location { get; init; } = ""; + + /// Sort order within its navigation location; smaller is earlier. + [JsonPropertyName("order")] + public double Order { get; init; } + + /// URL slug (lowercase, no umlauts), e.g. "board" -> /board. + [JsonPropertyName("slug")] + public string Slug { get; init; } = ""; + + /// + /// Dynamic blocks to render below the body: any of "posts", "events", "faqs". + /// Empty for a plain text page. + /// + [JsonPropertyName("embed")] + public IReadOnlyList Embed { get; init; } = []; + + /// Whether the page is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs new file mode 100644 index 0000000..382200c --- /dev/null +++ b/Elternbeirat.Contracts/Post.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// A news post. Sorted by , newest first. +public record Post +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// + /// Publication date. Stored as UTC by PocketBase but read as local time + /// (Europe/Berlin) by convention; only the date part is shown. + /// + [JsonPropertyName("date")] + public DateTime Date { get; init; } + + /// Post heading shown to visitors; may contain umlauts and spaces. + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + /// Post body in Markdown. + [JsonPropertyName("body")] + public string Body { get; init; } = ""; + + /// URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar. + [JsonPropertyName("slug")] + public string Slug { get; init; } = ""; + + /// Whether the post is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj b/Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj new file mode 100644 index 0000000..1091fcc --- /dev/null +++ b/Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Elternbeirat.PocketBase/LocalDateTimeConverter.cs b/Elternbeirat.PocketBase/LocalDateTimeConverter.cs new file mode 100644 index 0000000..ec9ee0d --- /dev/null +++ b/Elternbeirat.PocketBase/LocalDateTimeConverter.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elternbeirat.PocketBase; + +/// +/// Shared parsing of a PocketBase date string as wall-clock time. The trailing +/// "Z" is stripped rather than honoured, so the number is taken at face value and +/// the result carries -- no timezone shift. +/// +internal static class WallClock +{ + public static DateTime Parse(string raw) + { + // Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC + // and convert it to local time (which would shift 19:30 to 20:30/21:30). + var value = raw.EndsWith('Z') ? raw[..^1] : raw; + var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture, + DateTimeStyles.None); + return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified); + } +} + +/// +/// Reads PocketBase date values as local wall-clock time. +/// +/// PocketBase stores every date in UTC and serializes it with a trailing "Z" +/// (e.g. "2026-10-08 19:30:00.000Z"). By project convention the stored number +/// IS the local time (Europe/Berlin) and the "Z" is ignored -- see the timezone +/// decision in the data model. This converter therefore parses the value and +/// returns it as an instant, so no +/// timezone shift is ever applied when the value is later formatted. +/// +/// +public sealed class LocalDateTimeConverter : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + return string.IsNullOrEmpty(raw) + ? default + : WallClock.Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", + CultureInfo.InvariantCulture)); +} + +/// +/// Nullable counterpart of . PocketBase sends +/// an empty string for an unset optional date (e.g. an event without an end); +/// that maps to null. +/// +public sealed class NullableLocalDateTimeConverter : JsonConverter +{ + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (string.IsNullOrEmpty(raw)) + return null; + + return WallClock.Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value is null) + writer.WriteStringValue(""); + else + writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", + CultureInfo.InvariantCulture)); + } +} diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs new file mode 100644 index 0000000..cb96f81 --- /dev/null +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -0,0 +1,59 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Elternbeirat.Contracts; + +namespace Elternbeirat.PocketBase; + +/// +/// Reads published content from a PocketBase instance over its REST API. +/// +/// One method per collection (pages, posts, events, faqs). Each returns only +/// records with public = true and lets PocketBase do the filtering and +/// sorting via query parameters. The is expected to have +/// its set to the PocketBase base URL, so it +/// is registered as a typed client via AddHttpClient. +/// +/// +public sealed class PocketBaseClient(HttpClient http) +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + options.Converters.Add(new LocalDateTimeConverter()); + options.Converters.Add(new NullableLocalDateTimeConverter()); + return options; + } + + /// Gets all public pages, ordered for navigation. + public Task> GetPagesAsync(CancellationToken ct = default) + => GetRecordsAsync("pages", "order", ct); + + /// Gets all public posts, newest first. + public Task> GetPostsAsync(CancellationToken ct = default) + => GetRecordsAsync("posts", "-date", ct); + + /// Gets all public events, earliest start first. + public Task> GetEventsAsync(CancellationToken ct = default) + => GetRecordsAsync("events", "start", ct); + + /// Gets all public FAQ entries. + public Task> GetFaqsAsync(CancellationToken ct = default) + => GetRecordsAsync("faqs", "topic", ct); + + private async Task> GetRecordsAsync(string collection, string sort, CancellationToken ct) + { + // filter=public=true keeps drafts out; perPage is large enough to fetch + // every record in a single page given the small content volume. + var url = $"/api/collections/{collection}/records" + + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + + $"&sort={Uri.EscapeDataString(sort)}"; + + var result = await http.GetFromJsonAsync>(url, JsonOptions, ct); + return result?.Items ?? []; + } +} diff --git a/Elternbeirat.PocketBase/RecordList.cs b/Elternbeirat.PocketBase/RecordList.cs new file mode 100644 index 0000000..8987ae2 --- /dev/null +++ b/Elternbeirat.PocketBase/RecordList.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.PocketBase; + +/// +/// The envelope PocketBase wraps a records list response in. Only +/// is used; the paging fields are ignored because content volumes are small and the +/// client requests a large page size in a single call. +/// +/// The record type inside items. +internal sealed record RecordList +{ + [JsonPropertyName("items")] + public IReadOnlyList Items { get; init; } = []; +} diff --git a/Elternbeirat.Web.Tests/.editorconfig b/Elternbeirat.Web.Tests/.editorconfig new file mode 100644 index 0000000..857393d --- /dev/null +++ b/Elternbeirat.Web.Tests/.editorconfig @@ -0,0 +1,8 @@ +# Test-project-only overrides. This file inherits from the root .editorconfig +# (root = true there) and applies on top of it for everything under this folder. + +[*.cs] +# Test methods use Given_When_Then style names with underscores, which is the +# common, readable convention for tests. CA1707 (no underscores in member names) +# stays enforced in production code, but is turned off here. +dotnet_diagnostic.CA1707.severity = none diff --git a/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj b/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj index bc3d902..6f7bbd9 100644 --- a/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj +++ b/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj @@ -8,19 +8,22 @@ - - - - - + + + + + + + + \ No newline at end of file diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs new file mode 100644 index 0000000..c520b9a --- /dev/null +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -0,0 +1,111 @@ +using Elternbeirat.PocketBase; + +namespace Elternbeirat.Web.Tests; + +/// +/// Integration tests that hit a live PocketBase instance. They read the base URL +/// from the PB_URL environment variable (falling back to the repo .env file) and +/// skip themselves when no URL is configured, so CI without PocketBase stays green. +/// +public sealed class PocketBaseClientTests : IDisposable +{ + // One HttpClient per test instance; xUnit creates a fresh instance per test, + // so this is disposed in Dispose() when the test finishes. + private readonly HttpClient _http = new(); + + public void Dispose() => _http.Dispose(); + + private static string? ResolvePocketBaseUrl() + { + var fromEnv = Environment.GetEnvironmentVariable("PB_URL"); + if (!string.IsNullOrWhiteSpace(fromEnv)) + return fromEnv; + + // Fall back to the repo .env (gitignored, holds PB_URL locally). + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var envPath = Path.Combine(dir.FullName, ".env"); + if (File.Exists(envPath)) + { + foreach (var line in File.ReadAllLines(envPath)) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("PB_URL=", StringComparison.Ordinal)) + return trimmed["PB_URL=".Length..].Trim(); + } + } + dir = dir.Parent; + } + + return null; + } + + private PocketBaseClient? CreateClient() + { + var url = ResolvePocketBaseUrl(); + if (string.IsNullOrWhiteSpace(url)) + return null; + + _http.BaseAddress = new Uri(url); + return new PocketBaseClient(_http); + } + + [Fact] + public async Task Events_load_from_live_instance() + { + var client = CreateClient(); + if (client is null) return; // PB_URL not configured; skip live test. + + var events = await client.GetEventsAsync(); + + events.ShouldNotBeEmpty(); + // Every event must at least have a title and a start. + events.ShouldAllBe(e => !string.IsNullOrWhiteSpace(e.Title) && e.Start != default); + } + + [Fact] + public async Task Event_time_is_read_as_wall_clock_not_shifted() + { + var client = CreateClient(); + if (client is null) return; // PB_URL not configured; skip live test. + + var events = await client.GetEventsAsync(); + + // The Elternbeiratssitzung is stored as 19:30; by the timezone convention + // the number is taken at face value, so no shift to 20:30/21:30 happens. + var meeting = events.FirstOrDefault(@event => + @event.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal)); + meeting.ShouldNotBeNull(); + meeting.Start.Hour.ShouldBe(19); + meeting.Start.Minute.ShouldBe(30); + meeting.Start.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public async Task Pages_load_with_navigation_fields() + { + var client = CreateClient(); + if (client is null) return; // PB_URL not configured; skip live test. + + var pages = await client.GetPagesAsync(); + + pages.ShouldNotBeEmpty(); + // Navigation depends on location + slug being present on every page. + pages.ShouldAllBe(p => + !string.IsNullOrWhiteSpace(p.Slug) && (p.Location == "header" || p.Location == "footer")); + } + + [Fact] + public async Task Posts_and_faqs_load_from_live_instance() + { + var client = CreateClient(); + if (client is null) return; // PB_URL not configured; skip live test. + + var posts = await client.GetPostsAsync(); + var faqs = await client.GetFaqsAsync(); + + posts.ShouldNotBeEmpty(); + faqs.ShouldNotBeEmpty(); + } +} diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index 38200e9..919306d 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -48,9 +48,9 @@ public sealed class RouteSmokeTests : IClassFixture - - + + + + + +