diff --git a/.editorconfig b/.editorconfig
index 74d6a2f..cf29b5f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -54,3 +54,9 @@ dotnet_diagnostic.CA1812.severity = none
# layout and is harmless (Blazor routing is not namespace-based). Renaming would
# break the folder convention for no benefit.
dotnet_diagnostic.CA1724.severity = none
+
+# CA1308 wants ToUpperInvariant over ToLowerInvariant. That advice is for
+# normalizing before a comparison, where a few characters do not round-trip. Here
+# lowercasing is the goal itself (URL slugs, which must be lowercase), not a step
+# before a comparison -- comparisons use StringComparison.OrdinalIgnoreCase instead.
+dotnet_diagnostic.CA1308.severity = none
diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs
index aef9878..7b5d894 100644
--- a/Elternbeirat.Contracts/Event.cs
+++ b/Elternbeirat.Contracts/Event.cs
@@ -14,8 +14,13 @@ public record Event
///
/// Gets the PocketBase record id.
///
+ ///
+ /// : every stored record has an id. Modeling it as
+ /// required states which fields a record must carry, independent of the store,
+ /// so a future data source has to supply them too.
+ ///
[JsonPropertyName("id")]
- public string Id { get; init; } = "";
+ public required string Id { get; init; }
///
/// Gets the start of the event as Europe/Berlin wall-clock time.
@@ -24,10 +29,12 @@ public record Event
/// PocketBase stores the value in UTC; it is converted to Berlin local time on
/// deserialization, with daylight saving applied. An all-day event uses 00:00
/// as the time.
+ /// : an event without a start has no place on the
+ /// calendar, and the field is required in PocketBase.
///
[JsonPropertyName("start")]
[JsonConverter(typeof(LocalDateTimeConverter))]
- public DateTime Start { get; init; }
+ public required DateTime Start { get; init; }
///
/// Gets the optional end of the event as Europe/Berlin wall-clock time.
@@ -42,8 +49,12 @@ public record Event
///
/// Gets the event name, e.g. "Elternbeiratssitzung".
///
+ ///
+ /// : an event without a name is incomplete, and the
+ /// field is required in PocketBase.
+ ///
[JsonPropertyName("title")]
- public string Title { get; init; } = "";
+ public required string Title { get; init; }
///
/// Gets the optional location, e.g. "Aula".
@@ -52,7 +63,7 @@ public record Event
/// The location, or if none is set.
///
[JsonPropertyName("location")]
- public string Location { get; init; } = "";
+ public string Location { get; init; } = string.Empty;
///
/// Gets the optional note, e.g. "Anmeldung erforderlich".
@@ -61,13 +72,7 @@ public record Event
/// The note, or if none is set.
///
[JsonPropertyName("note")]
- public string Note { get; init; } = "";
-
- ///
- /// Gets a value indicating whether the event is visible to visitors of the site.
- ///
- [JsonPropertyName("public")]
- public bool Public { get; init; }
+ public string Note { get; init; } = string.Empty;
///
/// Gets a value indicating whether the event has a wall-clock time, as opposed
diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs
index bc8dd38..f95f388 100644
--- a/Elternbeirat.Contracts/Faq.cs
+++ b/Elternbeirat.Contracts/Faq.cs
@@ -13,30 +13,41 @@ public record Faq
///
/// Gets the PocketBase record id.
///
+ ///
+ /// : every stored record has an id. Modeling it as
+ /// required states which fields a record must carry, independent of the store,
+ /// so a future data source has to supply them too.
+ ///
[JsonPropertyName("id")]
- public string Id { get; init; } = "";
+ public required string Id { get; init; }
///
/// Gets the question as a parent would phrase it.
///
+ ///
+ /// : a FAQ entry without a question is incomplete, and
+ /// the field is required in PocketBase.
+ ///
[JsonPropertyName("question")]
- public string Question { get; init; } = "";
+ public required string Question { get; init; }
///
/// Gets the answer as Markdown.
///
+ ///
+ /// : a FAQ entry exists to answer its question, so an
+ /// entry without an answer is incomplete. The field is required in PocketBase.
+ ///
[JsonPropertyName("answer")]
- public string Answer { get; init; } = "";
+ public required string Answer { get; init; }
///
/// Gets the topic the question is grouped under, e.g. "mensa".
///
+ ///
+ /// : the FAQ page groups entries by topic, so an entry
+ /// without one has no place. The field is required in PocketBase.
+ ///
[JsonPropertyName("topic")]
- public string Topic { get; init; } = "";
-
- ///
- /// Gets a value indicating whether the question is visible to visitors of the site.
- ///
- [JsonPropertyName("public")]
- public bool Public { get; init; }
+ public required string Topic { get; init; }
}
diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs
index 7204d72..ee17931 100644
--- a/Elternbeirat.Contracts/Page.cs
+++ b/Elternbeirat.Contracts/Page.cs
@@ -22,23 +22,30 @@ public record Page
///
/// Gets the PocketBase record id.
///
+ ///
+ /// : every stored record has an id. Modeling it as
+ /// required states which fields a record must carry, independent of the store,
+ /// so a future data source has to supply them too.
+ ///
[JsonPropertyName("id")]
- public string Id { get; init; } = "";
+ public required string Id { get; init; }
///
/// Gets the heading shown to visitors.
///
///
/// Free text; may contain umlauts and spaces. For the URL, see .
+ /// : a page without a heading is incomplete, and the
+ /// field is required in PocketBase.
///
[JsonPropertyName("title")]
- public string Title { get; init; } = "";
+ public required string Title { get; init; }
///
/// Gets the page body as Markdown.
///
[JsonPropertyName("body")]
- public string Body { get; init; } = "";
+ public string Body { get; init; } = string.Empty;
///
/// Gets the navigation menu the page appears in.
@@ -46,8 +53,12 @@ public record Page
///
/// Either "header" or "footer".
///
+ ///
+ /// : the navigation is built from this, and the field
+ /// is required in PocketBase, so every page belongs to one of the two menus.
+ ///
[JsonPropertyName("location")]
- public string Location { get; init; } = "";
+ public required string Location { get; init; }
///
/// Gets the sort order within the navigation menu given by .
@@ -62,10 +73,18 @@ public record Page
/// Gets the URL slug of the page, e.g. "board" for /board.
///
///
- /// A lowercase path segment without umlauts.
+ /// A URL path segment: lowercase letters, digits and single hyphens as
+ /// separators, no umlauts. PocketBase enforces this on save via a field
+ /// pattern (^[a-z0-9]+(-[a-z0-9]+)*$), so every stored slug is already
+ /// canonical and the app can use it verbatim in comparisons and generated URLs.
///
+ ///
+ /// : the slug is how a page is addressed, so a record
+ /// without one is broken. It is a required field in PocketBase, so deserializing
+ /// one that lacks it should fail loudly rather than yield a page with no URL.
+ ///
[JsonPropertyName("slug")]
- public string Slug { get; init; } = "";
+ public required string Slug { get; init; }
///
/// Gets the dynamic blocks rendered below the .
@@ -78,22 +97,20 @@ public record Page
public IReadOnlyList Embed { get; init; } = [];
///
- /// Gets a value indicating whether the page is visible to visitors of the site.
- ///
- [JsonPropertyName("public")]
- public bool Public { get; init; }
-
- ///
- /// Tests whether a slug is the home page's, ignoring case. This is the single
- /// definition of that comparison, so every caller treats home and
- /// Home alike.
+ /// Tests whether a slug is the home page's. This is the single definition of
+ /// that comparison, so every caller identifies the home page the same way.
///
+ ///
+ /// An ordinal (case-sensitive) comparison is enough because slugs are always
+ /// lowercase: PocketBase enforces that with a field pattern (see
+ /// ), and this is only ever called with a stored slug.
+ ///
///
- /// The slug to test.
+ /// The slug to test, e.g. from .
///
///
/// if is the home page's slug.
///
public static bool IsHomeSlug(string slug) =>
- string.Equals(slug, HomeSlug, StringComparison.OrdinalIgnoreCase);
+ string.Equals(slug, HomeSlug, StringComparison.Ordinal);
}
diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs
index 51933e7..0622c12 100644
--- a/Elternbeirat.Contracts/Post.cs
+++ b/Elternbeirat.Contracts/Post.cs
@@ -13,8 +13,13 @@ public record Post
///
/// Gets the PocketBase record id.
///
+ ///
+ /// : every stored record has an id. Modeling it as
+ /// required states which fields a record must carry, independent of the store,
+ /// so a future data source has to supply them too.
+ ///
[JsonPropertyName("id")]
- public string Id { get; init; } = "";
+ public required string Id { get; init; }
///
/// Gets the publication date as a calendar day.
@@ -34,28 +39,32 @@ public record Post
///
///
/// Free text; may contain umlauts and spaces. For the URL, see .
+ /// : a post without a heading is incomplete, and the
+ /// field is required in PocketBase.
///
[JsonPropertyName("title")]
- public string Title { get; init; } = "";
+ public required string Title { get; init; }
///
/// Gets the post body as Markdown.
///
[JsonPropertyName("body")]
- public string Body { get; init; } = "";
+ public string Body { get; init; } = string.Empty;
///
/// Gets the URL slug of the post, e.g. "herbstbasar" for /posts/herbstbasar.
///
///
- /// A lowercase path segment without umlauts.
+ /// A URL path segment: lowercase letters, digits and single hyphens as
+ /// separators, no umlauts. PocketBase enforces this on save via a field
+ /// pattern (^[a-z0-9]+(-[a-z0-9]+)*$), so every stored slug is already
+ /// canonical and the app can use it verbatim in comparisons and generated URLs.
///
+ ///
+ /// : the slug is how a post is addressed, so a record
+ /// without one is broken. It is a required field in PocketBase, so deserializing
+ /// one that lacks it should fail loudly rather than yield a post with no URL.
+ ///
[JsonPropertyName("slug")]
- public string Slug { get; init; } = "";
-
- ///
- /// Gets a value indicating whether the post is visible to visitors of the site.
- ///
- [JsonPropertyName("public")]
- public bool Public { get; init; }
+ public required string Slug { get; init; }
}
diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs
index 5e68cfa..5438431 100644
--- a/Elternbeirat.PocketBase/PocketBaseClient.cs
+++ b/Elternbeirat.PocketBase/PocketBaseClient.cs
@@ -24,15 +24,15 @@ namespace Elternbeirat.PocketBase;
/// set to the PocketBase base URL.
///
///
-///
+///
/// The HTTP client; its must point to PocketBase.
///
-public sealed class PocketBaseClient(HttpClient http)
+public sealed class PocketBaseClient(HttpClient httpClient)
{
///
/// Gets all public pages, ordered by .
///
- ///
+ ///
/// A token to cancel the request.
///
///
@@ -45,10 +45,10 @@ public sealed class PocketBaseClient(HttpClient http)
/// The response could not be deserialized.
///
///
- /// was canceled or the request timed out.
+ /// was canceled or the request timed out.
///
- public Task> GetPagesAsync(CancellationToken ct = default)
- => GetRecordsAsync("pages", "order", ct);
+ public Task> GetPagesAsync(CancellationToken token = default)
+ => GetRecordsAsync("pages", "order", token);
///
/// Gets all public posts, ordered by , newest first.
@@ -58,8 +58,8 @@ public sealed class PocketBaseClient(HttpClient http)
/// The public posts, or an empty list if there are none.
///
///
- public Task> GetPostsAsync(CancellationToken ct = default)
- => GetRecordsAsync("posts", "-date", ct);
+ public Task> GetPostsAsync(CancellationToken token = default)
+ => GetRecordsAsync("posts", "-date", token);
///
/// Gets all public events, ordered by , earliest first.
@@ -69,8 +69,8 @@ public sealed class PocketBaseClient(HttpClient http)
/// The public events, or an empty list if there are none.
///
///
- public Task> GetEventsAsync(CancellationToken ct = default)
- => GetRecordsAsync("events", "start", ct);
+ public Task> GetEventsAsync(CancellationToken token = default)
+ => GetRecordsAsync("events", "start", token);
///
/// Gets all public FAQ entries, ordered by .
@@ -80,8 +80,8 @@ public sealed class PocketBaseClient(HttpClient http)
/// The public FAQ entries, or an empty list if there are none.
///
///
- public Task> GetFaqsAsync(CancellationToken ct = default)
- => GetRecordsAsync("faqs", "topic", ct);
+ public Task> GetFaqsAsync(CancellationToken token = default)
+ => GetRecordsAsync("faqs", "topic", token);
///
/// Gets all public records of a collection in a single request.
@@ -95,7 +95,7 @@ public sealed class PocketBaseClient(HttpClient http)
///
/// The PocketBase sort expression; a leading - sorts descending.
///
- ///
+ ///
/// A token to cancel the request.
///
///
@@ -104,7 +104,7 @@ public sealed class PocketBaseClient(HttpClient http)
private async Task> GetRecordsAsync(
string collection,
string sort,
- CancellationToken ct)
+ CancellationToken token)
{
// filter=public=true keeps drafts out; perPage is large enough to fetch
// every record in a single page given the small content volume.
@@ -112,7 +112,7 @@ public sealed class PocketBaseClient(HttpClient http)
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}";
- var result = await http.GetFromJsonAsync>(url, ct);
+ var result = await httpClient.GetFromJsonAsync>(url, token);
return result?.Items ?? [];
}
}
diff --git a/Elternbeirat.Web.Tests/NavBuilderTests.cs b/Elternbeirat.Web.Tests/NavBuilderTests.cs
index c8cdedc..f12c2e1 100644
--- a/Elternbeirat.Web.Tests/NavBuilderTests.cs
+++ b/Elternbeirat.Web.Tests/NavBuilderTests.cs
@@ -8,8 +8,10 @@ namespace Elternbeirat.Web.Tests;
///
public class NavBuilderTests
{
+ // Id is required on the record but irrelevant to navigation, so a fixed
+ // placeholder keeps these cases focused on slug/location/order/title.
private static Page Page(string slug, string location, double order, string title) =>
- new() { Slug = slug, Location = location, Order = order, Title = title };
+ new() { Id = "test", Slug = slug, Location = location, Order = order, Title = title };
[Fact]
public void Header_keeps_only_header_pages_ordered_by_order()
diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs
index 26b2ecc..c8bc2ba 100644
--- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs
+++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs
@@ -19,7 +19,6 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
// Two seeded events, earliest start first.
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
- events.ShouldAllBe(e => e.Public);
}
[Fact]
diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
index 7dfc87f..3f4bc47 100644
--- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs
+++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
@@ -335,6 +335,6 @@ public sealed class PocketBaseFixture : IAsyncLifetime
private sealed record AuthResponse
{
[System.Text.Json.Serialization.JsonPropertyName("token")]
- public string Token { get; init; } = "";
+ public string Token { get; init; } = string.Empty;
}
}
diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs
index ed809fc..8c187ce 100644
--- a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs
+++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs
@@ -2,23 +2,27 @@ using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components;
-using Microsoft.AspNetCore.Http;
namespace Elternbeirat.Web.Features.Pages;
public partial class ContentPage
{
- [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load page '{slug}' from PocketBase.")]
- private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception);
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Could not load page '{slug}' from PocketBase.")]
+ private static partial void LogLoadFailed(
+ ILogger logger,
+ string slug,
+ Exception exception);
[Parameter]
- public string Slug { get; set; } = "";
+ public string Slug { get; set; } = string.Empty;
[Inject]
- private PocketBaseClient PocketBase { get; set; } = default!;
+ private PocketBaseClient PocketBase { get; set; } = null!;
[Inject]
- private ILogger Logger { get; set; } = default!;
+ private ILogger Logger { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs
index 39e1df1..b3a68d6 100644
--- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs
+++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs
@@ -15,7 +15,7 @@ public partial class PostDetail
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception);
[Parameter]
- public string Slug { get; set; } = "";
+ public string Slug { get; set; } = string.Empty;
[Inject]
private PocketBaseClient PocketBase { get; set; } = default!;
diff --git a/docs/redaktion.md b/docs/redaktion.md
index f317199..4dc88be 100644
--- a/docs/redaktion.md
+++ b/docs/redaktion.md
@@ -87,8 +87,9 @@ sofort live, ohne Code und ohne Rebuild:
5. Optional `location` (`header`/`footer`) **und** `order` setzen, damit die Seite
ins Menü kommt; optional `embed` für eingebettete Blöcke (siehe unten).
-- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`,
- `board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
+- **Slug bleibt englisch und ohne Umlaute** — `imprint`, `privacy`, `contact`,
+ `board`, `patrons`; erzwungen per Pattern (siehe *Slug-Regel wird erzwungen*
+ unten). Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
- **Reservierte Slugs.** `posts`, `events`, `faqs` und `/` (die Startseite `home`)
gehören den festen Listen-/Sonderansichten. Eine `pages`-Seite mit einem dieser
Slugs wird von der jeweiligen Ansicht verdeckt und **nicht** angezeigt; in der
@@ -105,6 +106,26 @@ sofort live, ohne Code und ohne Rebuild:
> Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins
> Leere (404).
+### Slug-Regel wird erzwungen
+
+Ein Slug muss ein sauberes URL-Segment sein: **Kleinbuchstaben, Ziffern und
+einzelne Bindestriche als Trenner, keine Umlaute** (`ueber-uns`, nicht `Über uns`).
+Das ist keine Bitte, sondern eine Feldregel: PocketBase weist einen Slug ab, der
+sie verletzt, und zeigt beim Speichern einen Fehler. Gibst du `Overview` ein,
+kommst du erst weiter, wenn du `overview` daraus machst. Die Website verlässt sich
+darauf — sie repariert einen Slug nicht mehr nachträglich.
+
+Die Regel steckt als **Pattern** am Feld `slug`. So ist sie gesetzt (einmalig,
+pro Collection mit einem Slug — `pages` **und** `posts`):
+
+1. Collection öffnen → *Edit collection* (oben rechts) → Feld `slug`.
+2. Am Feld auf das **Zahnrad** (Feldoptionen) → **Pattern**.
+3. Als Pattern eintragen: `^[a-z0-9]+(-[a-z0-9]+)*$`
+4. **Save** (Feld) und **Save** (Collection).
+
+Zum Prüfen: `board`, `ueber-uns`, `faq-lunch` werden angenommen; `Overview`,
+`über-uns`, `my page`, `-x` und `a--b` werden abgewiesen.
+
---
## Beiträge (`posts`)
@@ -116,7 +137,9 @@ der Startseite als Vorschau angeteasert.
- `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt
hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung
aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein.
-- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`).
+- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`) —
+ dieselbe erzwungene Feldregel wie bei Seiten (siehe *Seiten → Slug-Regel wird
+ erzwungen*).
---
diff --git a/pb/pb_migrations/collections_schema.json b/pb/pb_migrations/collections_schema.json
index 37c7848..302f8da 100644
--- a/pb/pb_migrations/collections_schema.json
+++ b/pb/pb_migrations/collections_schema.json
@@ -74,7 +74,7 @@
"max": 0,
"min": 0,
"name": "slug",
- "pattern": "",
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"presentable": false,
"primaryKey": false,
"required": true,
@@ -298,7 +298,7 @@
"max": 0,
"min": 0,
"name": "slug",
- "pattern": "",
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"presentable": false,
"primaryKey": false,
"required": true,
@@ -388,7 +388,7 @@
"pattern": "",
"presentable": false,
"primaryKey": false,
- "required": false,
+ "required": true,
"system": false,
"type": "text"
},
@@ -399,7 +399,7 @@
"maxSelect": 1,
"name": "topic",
"presentable": false,
- "required": false,
+ "required": true,
"system": false,
"type": "select",
"values": [