PocketBase as the data layer: client, compose stack, dev environment and tests #28
No files matched your search
+6
-4
@@ -1,14 +1,14 @@
|
||||
# Build-Ausgaben (werden im Container frisch erzeugt)
|
||||
# Build outputs (regenerated fresh inside the container)
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/out/
|
||||
|
||||
# IDE- und Tooling-Kram
|
||||
# IDE and tooling files
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Versionskontrolle und Doku, im Image nicht benoetigt
|
||||
# Version control and docs, not needed in the image
|
||||
.git/
|
||||
.gitea/
|
||||
.gitignore
|
||||
@@ -16,7 +16,9 @@
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# Docker-Dateien selbst
|
||||
# The Docker files themselves
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
compose.yaml
|
||||
compose.dev.yaml
|
||||
compose.test.yaml
|
||||
@@ -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<T>) 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
|
||||
@@ -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>/_/.
|
||||
PB_URL=http://<unraid-host>:8090
|
||||
PB_ADMIN_EMAIL=
|
||||
PB_ADMIN_PASSWORD=
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="DevEnvironment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings>
|
||||
<option name="envFilePath" value="" />
|
||||
<option name="envFilePaths">
|
||||
<list />
|
||||
</option>
|
||||
<option name="commandLineOptions" value="--build" />
|
||||
<!-- Base compose (loaded first); the dev overlay in sourceFilePath wins. -->
|
||||
<option name="secondarySourceFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/compose.yaml" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="services">
|
||||
<list>
|
||||
<option value="eb-pocketbase" />
|
||||
<option value="eb-web" />
|
||||
</list>
|
||||
</option>
|
||||
<!-- Dev overlay (primary = last applied): host ports + local web build. -->
|
||||
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.dev.yaml" />
|
||||
<option name="upForceRecreate" value="true" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -12,8 +12,9 @@ nachlesen, bevor eine davon in Frage gestellt wird.
|
||||
| Thema | Datei |
|
||||
|---|---|
|
||||
| Warum SSR statt WASM, warum keine DB (AE-1 bis AE-4) | `docs/architektur.md` |
|
||||
| Stack lokal starten, Tests, compose-Overlays | `docs/entwicklung.md` |
|
||||
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
||||
| Inhalte anlegen und ändern | `docs/inhalte-pflegen.md` |
|
||||
| Inhalte anlegen und ändern (PocketBase-Admin) | `docs/redaktion.md` |
|
||||
| Impressum, Datenschutz, Fotos | `docs/recht.md` |
|
||||
| Offene Arbeit, Meilensteine, offene Punkte | Gitea-Issues (Milestone „Elternbeirat-Website") |
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project>
|
||||
|
||||
<!-- Solution-wide build settings, applied to every project. Package versions
|
||||
live in Directory.Packages.props; this file carries code-quality settings. -->
|
||||
<PropertyGroup>
|
||||
<!-- Turn on the full set of built-in .NET analyzers (code quality + style),
|
||||
including the opt-in rules. -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisMode>All</AnalysisMode>
|
||||
<AnalysisLevel>latest-all</AnalysisLevel>
|
||||
|
||||
<!-- Enforce .editorconfig code-style rules as part of the build, not just
|
||||
in the IDE. -->
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
|
||||
<!-- Any warning fails the build, so quality issues cannot be ignored. -->
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project>
|
||||
|
||||
<!-- Central Package Management: NuGet versions live here, the project files
|
||||
reference packages by name only. See
|
||||
https://learn.microsoft.com/nuget/consume-packages/central-package-management -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Markdig" Version="0.38.0" />
|
||||
<PackageVersion Include="YamlDotNet" Version="16.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Test-only packages. -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+8
-2
@@ -4,9 +4,15 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy only the csproj and restore first, so the NuGet restore layer stays
|
||||
# cached as long as the dependencies do not change.
|
||||
# Copy the solution-wide build files first (Central Package Management lives in
|
||||
# Directory.Packages.props; without it a restore fails with NU1015). Then the
|
||||
# csproj of every project in the web app's dependency tree. Restoring before
|
||||
# copying the rest of the source keeps the NuGet layer cached as long as the
|
||||
# dependencies do not change.
|
||||
COPY Directory.Build.props Directory.Packages.props ./
|
||||
COPY Elternbeirat.Web/Elternbeirat.Web.csproj Elternbeirat.Web/
|
||||
COPY Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj Elternbeirat.PocketBase/
|
||||
COPY Elternbeirat.Contracts/Elternbeirat.Contracts.csproj Elternbeirat.Contracts/
|
||||
RUN dotnet restore Elternbeirat.Web/Elternbeirat.Web.csproj
|
||||
|
||||
# Then the rest of the source.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.Contracts;
|
||||
|
||||
/// <summary>A calendar entry. Sorted by <see cref="Start"/>.</summary>
|
||||
public record Event
|
||||
{
|
||||
/// <summary>PocketBase record id.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonPropertyName("start")]
|
||||
public DateTime Start { get; init; }
|
||||
|
||||
/// <summary>Optional end of the event; null when unset.</summary>
|
||||
[JsonPropertyName("end")]
|
||||
public DateTime? End { get; init; }
|
||||
|
||||
/// <summary>Event name, e.g. "Elternbeiratssitzung".</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
/// <summary>Optional location, e.g. "Aula".</summary>
|
||||
[JsonPropertyName("location")]
|
||||
public string Location { get; init; } = "";
|
||||
|
||||
/// <summary>Optional note, e.g. "Anmeldung erforderlich".</summary>
|
||||
[JsonPropertyName("note")]
|
||||
public string Note { get; init; } = "";
|
||||
|
||||
/// <summary>Whether the event is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A single question and answer, grouped on the FAQ page by <see cref="Topic"/>.
|
||||
/// </summary>
|
||||
public record Faq
|
||||
{
|
||||
/// <summary>PocketBase record id.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>The question as a parent would phrase it.</summary>
|
||||
[JsonPropertyName("question")]
|
||||
public string Question { get; init; } = "";
|
||||
|
||||
/// <summary>The answer in Markdown.</summary>
|
||||
[JsonPropertyName("answer")]
|
||||
public string Answer { get; init; } = "";
|
||||
|
||||
/// <summary>Topic the question is grouped under, e.g. "mensa".</summary>
|
||||
[JsonPropertyName("topic")]
|
||||
public string Topic { get; init; } = "";
|
||||
|
||||
/// <summary>Whether the question is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A content page. Pages also drive the site navigation: <see cref="Location"/>
|
||||
/// and <see cref="Order"/> decide where and in which order a page appears in the
|
||||
/// header or footer menu, and <see cref="Embed"/> lists dynamic blocks (posts,
|
||||
/// events, faqs) rendered below the page body.
|
||||
/// </summary>
|
||||
public record Page
|
||||
{
|
||||
/// <summary>PocketBase record id.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>Heading shown to visitors; may contain umlauts and spaces.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
/// <summary>Page body in Markdown.</summary>
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; init; } = "";
|
||||
|
||||
/// <summary>Where the page appears in the navigation: "header" or "footer".</summary>
|
||||
[JsonPropertyName("location")]
|
||||
public string Location { get; init; } = "";
|
||||
|
||||
/// <summary>Sort order within its navigation location; smaller is earlier.</summary>
|
||||
[JsonPropertyName("order")]
|
||||
public double Order { get; init; }
|
||||
|
||||
/// <summary>URL slug (lowercase, no umlauts), e.g. "board" -> /board.</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic blocks to render below the body: any of "posts", "events", "faqs".
|
||||
/// Empty for a plain text page.
|
||||
/// </summary>
|
||||
[JsonPropertyName("embed")]
|
||||
public IReadOnlyList<string> Embed { get; init; } = [];
|
||||
|
||||
/// <summary>Whether the page is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.Contracts;
|
||||
|
||||
/// <summary>A news post. Sorted by <see cref="Date"/>, newest first.</summary>
|
||||
public record Post
|
||||
{
|
||||
/// <summary>PocketBase record id.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Publication date. Stored as UTC by PocketBase but read as local time
|
||||
/// (Europe/Berlin) by convention; only the date part is shown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime Date { get; init; }
|
||||
|
||||
/// <summary>Post heading shown to visitors; may contain umlauts and spaces.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
/// <summary>Post body in Markdown.</summary>
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; init; } = "";
|
||||
|
||||
/// <summary>URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar.</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; init; } = "";
|
||||
|
||||
/// <summary>Whether the post is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elternbeirat.Contracts\Elternbeirat.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.PocketBase;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads PocketBase date values as local wall-clock time.
|
||||
/// <para>
|
||||
/// 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 <see cref="DateTimeKind.Unspecified"/> instant, so no
|
||||
/// timezone shift is ever applied when the value is later formatted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. PocketBase sends
|
||||
/// an empty string for an unset optional date (e.g. an event without an end);
|
||||
/// that maps to null.
|
||||
/// </summary>
|
||||
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Elternbeirat.Contracts;
|
||||
|
||||
namespace Elternbeirat.PocketBase;
|
||||
|
||||
/// <summary>
|
||||
/// Reads published content from a PocketBase instance over its REST API.
|
||||
/// <para>
|
||||
/// One method per collection (pages, posts, events, faqs). Each returns only
|
||||
/// records with <c>public = true</c> and lets PocketBase do the filtering and
|
||||
/// sorting via query parameters. The <see cref="HttpClient"/> is expected to have
|
||||
/// its <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL, so it
|
||||
/// is registered as a typed client via <c>AddHttpClient</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Gets all public pages, ordered for navigation.</summary>
|
||||
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Page>("pages", "order", ct);
|
||||
|
||||
/// <summary>Gets all public posts, newest first.</summary>
|
||||
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
||||
|
||||
/// <summary>Gets all public events, earliest start first.</summary>
|
||||
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Event>("events", "start", ct);
|
||||
|
||||
/// <summary>Gets all public FAQ entries.</summary>
|
||||
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
||||
|
||||
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(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<RecordList<T>>(url, JsonOptions, ct);
|
||||
return result?.Items ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.PocketBase;
|
||||
|
||||
/// <summary>
|
||||
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/>
|
||||
/// is used; the paging fields are ignored because content volumes are small and the
|
||||
/// client requests a large page size in a single call.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The record type inside <c>items</c>.</typeparam>
|
||||
internal sealed record RecordList<T>
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<T> Items { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
|
||||
# CA1861 wants constant array arguments hoisted to static readonly fields to avoid
|
||||
# re-allocation. In one-time test setup (collection seeding) that micro-optimization
|
||||
# has no benefit and inline arrays keep the seed data readable.
|
||||
dotnet_diagnostic.CA1861.severity = none
|
||||
|
||||
# CA1001 wants a type with a disposable field to implement IDisposable. The xunit
|
||||
# fixture already owns and disposes its fields in IAsyncLifetime.DisposeAsync, which
|
||||
# xunit calls; the analyzer just does not recognize that as the dispose contract.
|
||||
# Adding IAsyncDisposable clashes with IAsyncLifetime's Task-returning DisposeAsync.
|
||||
dotnet_diagnostic.CA1001.severity = none
|
||||
@@ -8,19 +8,22 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elternbeirat.Web\Elternbeirat.Web.csproj" />
|
||||
<ProjectReference Include="..\Elternbeirat.PocketBase\Elternbeirat.PocketBase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,99 @@
|
||||
using Elternbeirat.PocketBase;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the <see cref="PocketBaseClient"/> against a throwaway PocketBase container
|
||||
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
||||
/// the tests assert on exact values, and they need no network to the live instance.
|
||||
/// </summary>
|
||||
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture<PocketBaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Events_load_sorted_by_start()
|
||||
{
|
||||
var client = pocketBase.CreateClient();
|
||||
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
// Two seeded events, earliest start first.
|
||||
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
||||
events.ShouldAllBe(e => e.Public);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Event_time_is_read_as_wall_clock_not_shifted()
|
||||
{
|
||||
var client = pocketBase.CreateClient();
|
||||
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
// The meeting is seeded 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(e =>
|
||||
e.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 Event_without_end_maps_to_null()
|
||||
{
|
||||
var client = pocketBase.CreateClient();
|
||||
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
var meeting = events.Single(e => e.Title == "Elternbeiratssitzung");
|
||||
var basar = events.Single(e => e.Title == "Herbstbasar");
|
||||
meeting.End.ShouldBeNull(); // no end seeded
|
||||
basar.End.ShouldNotBeNull(); // end seeded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Posts_load_newest_first()
|
||||
{
|
||||
var client = pocketBase.CreateClient();
|
||||
|
||||
var posts = await client.GetPostsAsync();
|
||||
|
||||
// Sorted by -date: March before January.
|
||||
posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pages_exclude_non_public_records()
|
||||
{
|
||||
var client = pocketBase.CreateClient();
|
||||
|
||||
var pages = await client.GetPagesAsync();
|
||||
|
||||
// Three pages seeded, one with public=false; the draft must be filtered out.
|
||||
pages.Select(p => p.Slug).ShouldBe(["home", "contact"]);
|
||||
pages.ShouldNotContain(p => p.Slug == "draft");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Page_embed_is_read_as_list()
|
||||
{
|
||||
var client = pocketBase.CreateClient();
|
||||
|
||||
var pages = await client.GetPagesAsync();
|
||||
|
||||
var home = pages.Single(p => p.Slug == "home");
|
||||
home.Embed.ShouldBe(["posts", "events"], ignoreOrder: true);
|
||||
home.Location.ShouldBe("header");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Faqs_load_from_instance()
|
||||
{
|
||||
var client = pocketBase.CreateClient();
|
||||
|
||||
var faqs = await client.GetFaqsAsync();
|
||||
|
||||
faqs.Select(f => f.Topic).ShouldBe(["mensa", "schliessfach"], ignoreOrder: true);
|
||||
faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Elternbeirat.PocketBase;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a throwaway PocketBase container once per test run, creates the four
|
||||
/// content collections and seeds them with a small, known data set. The tests run
|
||||
/// against this instance instead of the live one, so they are hermetic (no network
|
||||
/// to Unraid), reproducible (fixed data) and safe (isolated from production).
|
||||
/// <para>
|
||||
/// The container is started from the real <c>compose.yaml</c> + <c>compose.dev.yaml</c>
|
||||
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
||||
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
||||
/// in one place -- the compose files -- and the tests always exercise the same
|
||||
/// PocketBase the stack runs. The seed data is deliberately fixed here rather than
|
||||
/// exported from the real instance, so tests assert against values this file
|
||||
/// controls. Requires Docker with the Compose plugin.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
{
|
||||
// The service name and container port as defined in the compose files. Everything
|
||||
// else about the container (image version, superuser env, host port) comes from
|
||||
// compose, so there is nothing to keep in sync with it here.
|
||||
private const string ServiceName = "eb-pocketbase";
|
||||
private const int PocketBasePort = 8090;
|
||||
|
||||
// The superuser the dev overlay creates (PB_ADMIN_EMAIL/PASSWORD in
|
||||
// compose.dev.yaml); used only to authenticate for the one-time seed.
|
||||
private const string AdminEmail = "test@example.com";
|
||||
private const string AdminPassword = "test-password"; // >= 8 chars (PB rule)
|
||||
|
||||
// A fixed compose project name for the tests, sibling to the dev stack
|
||||
// ("eb-stack"). Fixed (not per-run) so the
|
||||
// container names are predictable and a leftover from an aborted run can be
|
||||
// cleaned up before the next start. Because it is its own project, it never
|
||||
// touches the dev stack -- the container_name is cleared in the test overlay so
|
||||
// both projects can coexist.
|
||||
private const string Project = "eb-test-stack";
|
||||
|
||||
// One HttpClient shared by all tests through the client; the fixture owns it and
|
||||
// disposes it in DisposeAsync. Its BaseAddress is set once the container is up.
|
||||
private readonly HttpClient _http = new();
|
||||
|
||||
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
||||
public PocketBaseClient CreateClient() => new(_http);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
||||
// (a hard kill), so the fixed-name project starts from a clean, empty volume.
|
||||
await ComposeAsync("down", "--volumes", "--remove-orphans");
|
||||
// `up --wait` blocks until the service is healthy (the compose healthcheck),
|
||||
// so once this returns PocketBase is ready to answer.
|
||||
await ComposeAsync("up", "--detach", "--wait", ServiceName);
|
||||
_http.BaseAddress = await ResolveBaseUrlAsync();
|
||||
await SeedAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_http.Dispose();
|
||||
// Remove containers, network and the (dev) volume for this project.
|
||||
await ComposeAsync("down", "--volumes");
|
||||
}
|
||||
|
||||
/// <summary>Reads the host address compose bound the service port to.</summary>
|
||||
private static async Task<Uri> ResolveBaseUrlAsync()
|
||||
{
|
||||
// `docker compose port <service> <port>` prints e.g. "0.0.0.0:49153".
|
||||
var mapping = (await ComposeAsync(
|
||||
"port", ServiceName, PocketBasePort.ToString(CultureInfo.InvariantCulture))).Trim();
|
||||
var host = mapping[..mapping.LastIndexOf(':')];
|
||||
var port = mapping[(mapping.LastIndexOf(':') + 1)..];
|
||||
// 0.0.0.0 is a bind address, not something to connect to; use loopback.
|
||||
if (host is "0.0.0.0" or "::")
|
||||
host = "localhost";
|
||||
return new Uri($"http://{host}:{port}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs `docker compose -p <project> -f compose.yaml -f compose.dev.yaml <args>`
|
||||
/// from the repo root and returns its stdout, throwing on a non-zero exit.
|
||||
/// </summary>
|
||||
private static async Task<string> ComposeAsync(params string[] args)
|
||||
{
|
||||
var start = new ProcessStartInfo("docker")
|
||||
{
|
||||
WorkingDirectory = RepoRoot(),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
// compose -p <project> -f <base> -f <dev> -f <test> <args...>. The test
|
||||
// overlay swaps the dev overlay's fixed host port for a random one, so the
|
||||
// test stack does not fight a running dev stack over port 8090.
|
||||
start.ArgumentList.Add("compose");
|
||||
start.ArgumentList.Add("-p");
|
||||
start.ArgumentList.Add(Project);
|
||||
start.ArgumentList.Add("-f");
|
||||
start.ArgumentList.Add("compose.yaml");
|
||||
start.ArgumentList.Add("-f");
|
||||
start.ArgumentList.Add("compose.dev.yaml");
|
||||
start.ArgumentList.Add("-f");
|
||||
start.ArgumentList.Add("compose.test.yaml");
|
||||
foreach (var arg in args)
|
||||
start.ArgumentList.Add(arg);
|
||||
|
||||
using var process = Process.Start(start)
|
||||
?? throw new InvalidOperationException("Could not start the docker process.");
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"`docker compose {string.Join(' ', args)}` failed ({process.ExitCode}): {stderr}");
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/// <summary>Repo root, resolved by walking up from the test assembly to compose.yaml.</summary>
|
||||
private static string RepoRoot()
|
||||
{
|
||||
// The test binary sits under <repo>/Elternbeirat.Web.Tests/bin/<config>/<tfm>;
|
||||
// walk up until the directory that holds the compose files (the repo root).
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "compose.yaml")))
|
||||
dir = dir.Parent;
|
||||
|
||||
return dir?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate the repo root (compose.yaml).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates as superuser, then creates the collections and records. This
|
||||
/// mirrors the shape the client reads, not the full production schema.
|
||||
/// </summary>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
var token = await AuthenticateAsync(_http);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
||||
|
||||
await CreateCollectionsAsync(_http);
|
||||
await SeedRecordsAsync(_http);
|
||||
|
||||
// Drop the superuser token so the tests read as an anonymous visitor would,
|
||||
// exercising the public list/view rules rather than a privileged bypass.
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
}
|
||||
|
||||
private static async Task<string> AuthenticateAsync(HttpClient http)
|
||||
{
|
||||
// The superuser upsert runs before serve, so once /api/health answers the
|
||||
// account exists; a couple of retries still guard against a race.
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
var response = await http.PostAsJsonAsync(
|
||||
"/api/collections/_superusers/auth-with-password",
|
||||
new { identity = AdminEmail, password = AdminPassword });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
return payload?.Token ?? throw new InvalidOperationException("No auth token returned.");
|
||||
}
|
||||
|
||||
if (attempt >= 5)
|
||||
response.EnsureSuccessStatusCode(); // give up: throw with the status.
|
||||
|
||||
await Task.Delay(500);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateCollectionsAsync(HttpClient http)
|
||||
{
|
||||
// listRule/viewRule = "" means publicly readable; the client's
|
||||
// filter=public=true does the visibility gating on top.
|
||||
await CreateCollectionAsync(http, "pages", new object[]
|
||||
{
|
||||
new { name = "title", type = "text", required = true },
|
||||
new { name = "body", type = "editor" },
|
||||
new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } },
|
||||
new { name = "order", type = "number" },
|
||||
new { name = "slug", type = "text", required = true },
|
||||
new { name = "embed", type = "select", maxSelect = 3, values = new[] { "posts", "events", "faqs" } },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
|
||||
await CreateCollectionAsync(http, "posts", new object[]
|
||||
{
|
||||
new { name = "date", type = "date" },
|
||||
new { name = "title", type = "text", required = true },
|
||||
new { name = "body", type = "editor" },
|
||||
new { name = "slug", type = "text", required = true },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
|
||||
await CreateCollectionAsync(http, "events", new object[]
|
||||
{
|
||||
new { name = "start", type = "date", required = true },
|
||||
new { name = "end", type = "date" },
|
||||
new { name = "title", type = "text", required = true },
|
||||
new { name = "location", type = "text" },
|
||||
new { name = "note", type = "text" },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
|
||||
await CreateCollectionAsync(http, "faqs", new object[]
|
||||
{
|
||||
new { name = "question", type = "text", required = true },
|
||||
new { name = "answer", type = "editor" },
|
||||
new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task CreateCollectionAsync(HttpClient http, string name, object[] fields)
|
||||
{
|
||||
var response = await http.PostAsJsonAsync("/api/collections", new
|
||||
{
|
||||
name,
|
||||
type = "base",
|
||||
listRule = "",
|
||||
viewRule = "",
|
||||
fields,
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task SeedRecordsAsync(HttpClient http)
|
||||
{
|
||||
// Pages: one header page, one footer page. Both public.
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "Startseite", body = "# Willkommen", location = "header",
|
||||
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "Kontakt", body = "Mail an uns", location = "footer",
|
||||
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
// A draft page that must never appear (public=false).
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "Entwurf", body = "geheim", location = "header",
|
||||
order = 9, slug = "draft", embed = Array.Empty<string>(), @public = false,
|
||||
});
|
||||
|
||||
// Posts: newest first once sorted by -date.
|
||||
await CreateRecordAsync(http, "posts", new
|
||||
{
|
||||
date = "2026-03-01 00:00:00.000Z", title = "Neuer Vorstand",
|
||||
body = "Text", slug = "new-board", @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "posts", new
|
||||
{
|
||||
date = "2026-01-15 00:00:00.000Z", title = "Neue Sporthalle",
|
||||
body = "Text", slug = "new-hall", @public = true,
|
||||
});
|
||||
|
||||
// Events: the meeting carries the wall-clock time the timezone test checks.
|
||||
await CreateRecordAsync(http, "events", new
|
||||
{
|
||||
start = "2026-10-08 19:30:00.000Z", title = "Elternbeiratssitzung",
|
||||
location = "Aula", note = "", @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "events", new
|
||||
{
|
||||
start = "2026-11-22 09:00:00.000Z", end = "2026-11-22 13:00:00.000Z",
|
||||
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
||||
});
|
||||
|
||||
// Faqs: two topics.
|
||||
await CreateRecordAsync(http, "faqs", new
|
||||
{
|
||||
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
|
||||
topic = "mensa", @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "faqs", new
|
||||
{
|
||||
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
|
||||
topic = "schliessfach", @public = true,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task CreateRecordAsync(HttpClient http, string collection, object record)
|
||||
{
|
||||
var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private sealed record AuthResponse
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token")]
|
||||
public string Token { get; init; } = "";
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,9 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -60,8 +60,8 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.38.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Elternbeirat.Web.Services;
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Web.Services;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Elternbeirat.Web.Services;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an iCalendar (RFC 5545) document from the events so visitors can
|
||||
@@ -82,7 +83,7 @@ public static class IcsCalendar
|
||||
{
|
||||
var seed = $"{ev.Title}|{ev.Start:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"{Convert.ToHexString(hash)[..16].ToLowerInvariant()}@elternbeirat-igmh";
|
||||
return $"{Convert.ToHexString(hash)[..16]}@elternbeirat-igmh";
|
||||
}
|
||||
|
||||
private static string Local(DateTime value) =>
|
||||
@@ -93,11 +94,11 @@ public static class IcsCalendar
|
||||
|
||||
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
|
||||
private static string Escape(string value) => value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace(";", "\\;")
|
||||
.Replace(",", "\\,")
|
||||
.Replace("\r\n", "\\n")
|
||||
.Replace("\n", "\\n");
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal)
|
||||
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||
|
||||
// ICS lines are terminated with CRLF regardless of platform.
|
||||
private static void AppendLine(StringBuilder sb, string line) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Elternbeirat.Web.Components;
|
||||
using Elternbeirat.Web.Features.Events;
|
||||
using Elternbeirat.Web.Services;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="Elternbeirat.Contracts/Elternbeirat.Contracts.csproj" />
|
||||
<Project Path="Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj" />
|
||||
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
|
||||
<Project Path="Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,40 @@
|
||||
# Dev overlay: layered on top of compose.yaml for local development.
|
||||
# docker compose -f compose.yaml -f compose.dev.yaml up --build
|
||||
#
|
||||
# It adds host port mappings (so the stack is reachable from the browser), builds
|
||||
# the web image from local source instead of pulling it from the registry, and
|
||||
# swaps the Unraid pb_data bind mount for a throwaway named volume (the /mnt/user
|
||||
# path only exists on the server).
|
||||
|
||||
# No name: here on purpose -- the project name is set once in the base file.
|
||||
|
||||
services:
|
||||
eb-pocketbase:
|
||||
ports:
|
||||
- "8090:8090" # PocketBase admin UI at http://localhost:8090/_/
|
||||
environment:
|
||||
# Local only: on a fresh pb_data the image auto-creates this superuser, so
|
||||
# you skip the /_/ setup screen. Same credentials as the test fixture. This
|
||||
# stays out of the prod base -- there the superuser is set up by hand / #9.
|
||||
PB_ADMIN_EMAIL: test@example.com
|
||||
PB_ADMIN_PASSWORD: test-password
|
||||
volumes:
|
||||
# Local: a named Docker volume instead of the Unraid /mnt/user bind mount
|
||||
# from the base file (that path only exists on the server). The "!reset"
|
||||
# tag clears the inherited bind mount so only this volume applies.
|
||||
- !reset null
|
||||
- pb_data_dev:/pb_data
|
||||
|
||||
eb-web:
|
||||
# Build from local source instead of pulling the registry image, so local code
|
||||
# changes show up without a push+CI round-trip. image: "" clears the inherited
|
||||
# tag from the base, which forces compose to use the build below.
|
||||
image: ""
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5000:8080" # web app at http://localhost:5000
|
||||
|
||||
volumes:
|
||||
pb_data_dev: # throwaway local volume; remove with `docker volume rm`
|
||||
@@ -0,0 +1,23 @@
|
||||
# Test overlay: layered on top of compose.yaml + compose.dev.yaml, used only by the
|
||||
# integration tests (PocketBaseFixture). Its single job is to drop the fixed host
|
||||
# port 8090 the dev overlay binds, so the test stack does not fight the running dev
|
||||
# stack over that port. An empty host port ("8090" alone) lets Docker pick a random
|
||||
# free one; the tests read it back with `docker compose port`.
|
||||
#
|
||||
# docker compose -f compose.yaml -f compose.dev.yaml -f compose.test.yaml ...
|
||||
|
||||
services:
|
||||
eb-pocketbase:
|
||||
# Drop the fixed name from the base so compose auto-names the container
|
||||
# (eb-test-stack-eb-pocketbase-1). That keeps the test stack from fighting a
|
||||
# running dev stack -- which keeps the readable fixed names -- over the name
|
||||
# "eb-pocketbase". ("!reset null" clears the inherited scalar value.)
|
||||
container_name: !reset null
|
||||
# !override replaces the whole inherited ports list (a plain list would merge
|
||||
# additively and keep the dev overlay's 8090:8090). "8090" without a host port
|
||||
# lets Docker pick a random free one; the tests read it back with `compose port`.
|
||||
ports: !override
|
||||
- "8090"
|
||||
|
||||
eb-web:
|
||||
container_name: !reset null # see eb-pocketbase above (eb-web is not started in tests)
|
||||
+36
-14
@@ -1,24 +1,46 @@
|
||||
# TEST setup for the first run on Unraid (without NPM).
|
||||
# Base compose: production-like. Both services, no host port mappings (NPM is the
|
||||
# only path to the web app; the editors reach PocketBase through an NPM subdomain).
|
||||
# Only PocketBase's data is a volume; everything else lives inside the images.
|
||||
#
|
||||
# Pulls the finished image from the Gitea registry (the build happens on the
|
||||
# development machine, see docs/deployment.md) instead of building from source
|
||||
# on Unraid.
|
||||
# For local development use the dev overlay on top, which adds host ports and builds
|
||||
# the web image from source:
|
||||
# docker compose -f compose.yaml -f compose.dev.yaml up --build
|
||||
# (The Rider run config "DevEnvironment" does exactly this with one click.)
|
||||
#
|
||||
# Deliberately differs from the production setup:
|
||||
# - There is a port mapping (5000 outside -> 8080 inside) so the app is
|
||||
# reachable in the local network at http://<unraid>:5000. In production the
|
||||
# mapping is dropped; there NPM is the only path to the container (use the
|
||||
# external npm network instead, see docs/deployment.md).
|
||||
#
|
||||
# Once NPM is in place, this file is replaced by the production compose.
|
||||
# Startup order: PocketBase comes up first; once it reports healthy the web app
|
||||
# starts (depends_on -> service_healthy).
|
||||
|
||||
# Fixed project name, so the stack has a stable name in Docker regardless of the
|
||||
# folder name. Set once here in the base; overlays don't set their own name: (the
|
||||
# tests override it with -p eb-test-stack).
|
||||
name: eb-stack
|
||||
|
||||
services:
|
||||
eb-pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:0.40.4
|
||||
container_name: eb-pocketbase
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
# The only state that must survive a restart or redeploy.
|
||||
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
||||
healthcheck:
|
||||
# The image ships this endpoint; the web app waits for it to pass.
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
eb-web:
|
||||
image: gitea.anticarnist.de/tom/elternbeirat:latest
|
||||
container_name: eb-web
|
||||
container_name: eb-blazor
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
eb-pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
TZ: Europe/Berlin
|
||||
ports:
|
||||
- "5000:8080" # TEST access, remove in production
|
||||
# The web app reads content from PocketBase over the compose network.
|
||||
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
||||
+28
-54
@@ -4,11 +4,14 @@ How a new version of the website ends up on Unraid. Current state:
|
||||
**manual** — build the image locally, push it to the Gitea registry, pull it on
|
||||
Unraid. Automation via Gitea Actions comes later.
|
||||
|
||||
> **Test vs. production.** As long as the domain has not moved yet and NPM is not
|
||||
> in front of it, the container runs with a port mapping and is directly reachable
|
||||
> on the local network (`http://<unraid>:5000`). In production the mapping is gone
|
||||
> — then NPM is the only path to the container. The two compose variants are
|
||||
> described separately below.
|
||||
The site runs as a **two-container stack** (project `eb-stack`): the Blazor app
|
||||
(`eb-blazor`) plus a PocketBase container (`eb-pocketbase`) that holds the
|
||||
content. The stack is described by `compose.yaml` (the production base) with
|
||||
overlays layered on top.
|
||||
|
||||
> **Running it locally is a separate document.** For starting the stack on your
|
||||
> own machine and for how the tests run, see `entwicklung.md`. This document is
|
||||
> only about getting a built image onto Unraid.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,13 +47,9 @@ The Docker login then stores the token locally, so it only has to be entered onc
|
||||
3. Open a **Pull Request** against `main` in Gitea and merge it there.
|
||||
4. Only then build the release image from `main` (below).
|
||||
|
||||
**Dev images stay local.** For trying things out on your own machine:
|
||||
|
||||
```bash
|
||||
scripts/dev-build.sh # builds elternbeirat-web:dev-<branch>, pushes NOTHING
|
||||
```
|
||||
|
||||
This way a development state can never accidentally land in the registry as
|
||||
**Dev builds stay local.** For trying things out on your own machine, run the
|
||||
dev stack (`entwicklung.md`) — it builds the web image from source and pushes
|
||||
nothing, so a development state can never accidentally land in the registry as
|
||||
`:latest`.
|
||||
|
||||
## Rolling out a new version (manual)
|
||||
@@ -101,56 +100,31 @@ pitfalls learned from experience:
|
||||
|
||||
In the **Compose Manager Plus** plugin (Unraid web interface):
|
||||
|
||||
- **Docker** tab → **Compose** section → stack **elternbeirat** →
|
||||
- **Docker** tab → **Compose** section → stack **eb-stack** →
|
||||
**Compose Down**, then **Compose Up** (or "Pull" + "Up", depending on the plugin
|
||||
version), so that the new `:latest` is pulled.
|
||||
version), so that the new `:latest` is pulled. Only the `eb-blazor` image
|
||||
changes on a deploy; `eb-pocketbase` and its `pb_data` volume stay as they are.
|
||||
|
||||
> `docker compose up` does **not** automatically re-pull a `:latest` if an image of
|
||||
> the same name is already present locally. When in doubt, explicitly "Pull" first.
|
||||
|
||||
---
|
||||
|
||||
## compose variants
|
||||
## The compose files
|
||||
|
||||
### Test (now: without NPM, directly reachable on the LAN)
|
||||
`compose.yaml` is the **production** base and is the file Unraid uses. It defines
|
||||
both services with no host port mappings — in production NPM is the only path to
|
||||
the web app, and the editors reach PocketBase through an NPM subdomain (see #9).
|
||||
`eb-pocketbase` keeps its data on the bind mount
|
||||
`/mnt/user/appdata/pocketbase/pb_data`, which only exists on the server.
|
||||
|
||||
Present in the repo as `compose.yaml`. Pulls the registry image and maps port
|
||||
**5000 → 8080**:
|
||||
The two overlays (`compose.dev.yaml`, `compose.test.yaml`) are for local
|
||||
development and the tests and are **not** used on Unraid — see `entwicklung.md`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
eb-web:
|
||||
image: gitea.anticarnist.de/tom/elternbeirat:latest
|
||||
container_name: eb-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
TZ: Europe/Berlin
|
||||
ports:
|
||||
- "5000:8080" # TEST access, remove in production
|
||||
```
|
||||
|
||||
Reachable at `http://<unraid>:5000`.
|
||||
|
||||
### Production (later: only via NPM)
|
||||
|
||||
No `ports:` block, instead the external NPM Docker network:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
eb-web:
|
||||
image: gitea.anticarnist.de/tom/elternbeirat:latest
|
||||
container_name: eb-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
TZ: Europe/Berlin
|
||||
networks: [npm]
|
||||
|
||||
networks:
|
||||
npm:
|
||||
external: true
|
||||
```
|
||||
> **NPM network (still open).** The base does not yet attach `eb-blazor` to the
|
||||
> external NPM Docker network; while the domain has not moved, add a temporary
|
||||
> `ports:` mapping (e.g. `5000:8080`) to reach the app on the LAN, and swap it for
|
||||
> the `npm` external network once NPM sits in front. Tracked with #9.
|
||||
|
||||
---
|
||||
|
||||
@@ -162,7 +136,7 @@ which — unlike the moving `:latest` — never shifts. To roll back, replace
|
||||
and bring it back up:
|
||||
|
||||
```yaml
|
||||
image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # statt :latest
|
||||
image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # instead of :latest
|
||||
```
|
||||
|
||||
Which SHA tags are in the registry is shown by Gitea under
|
||||
@@ -190,7 +164,7 @@ Requires two repository secrets (Gitea → repo → **Settings** → **Actions**
|
||||
The workflow currently only builds and pushes — it does **not** run the tests
|
||||
yet, and it does **not** deploy. Those open steps (tests in the workflow,
|
||||
auto-deploy with a health gate via Watchtower, encrypting the registry token on
|
||||
Unraid) are tracked in `tasks/002-deployment-automatisieren.md`.
|
||||
Unraid) are tracked as Gitea issues under the "Elternbeirat-Website" milestone.
|
||||
|
||||
Two known pitfalls: the `act_runner` needs Docker socket access to build, and it
|
||||
must offer the `ubuntu-latest` label the workflow asks for. To check the runner:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Local development
|
||||
|
||||
How to run the site on your own machine and how the tests run. The app is one
|
||||
container (`eb-blazor`) that reads its content from a second container running
|
||||
PocketBase (`eb-pocketbase`); both come up together as a compose stack.
|
||||
|
||||
For deploying a built image to Unraid, see `deployment.md`. For the architecture
|
||||
reasoning, see `architektur.md`.
|
||||
|
||||
---
|
||||
|
||||
## The three compose files
|
||||
|
||||
There is one stack, described by a base file plus two overlays. You never edit
|
||||
the base for a local run — you layer an overlay on top of it.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `compose.yaml` | Base, production-shaped: both services, **no** host ports, PocketBase data on the Unraid bind mount. Also holds the fixed project name `eb-stack`. |
|
||||
| `compose.dev.yaml` | Dev overlay: adds host ports (5000, 8090), builds the web image from local source instead of pulling it, swaps the Unraid bind mount for a throwaway volume, and seeds a local PocketBase superuser. |
|
||||
| `compose.test.yaml` | Test overlay: used **only** by the integration tests. Drops the fixed host port and container names so a test run does not collide with a running dev stack. |
|
||||
|
||||
> **Project name.** `name: eb-stack` is set once, in the base. The overlays
|
||||
> deliberately don't set their own `name:` — a competing `name:` across `-f`
|
||||
> files resolves depending on load order (and the CLI and Rider load them in
|
||||
> opposite orders), so keeping it in one place avoids surprises. The tests
|
||||
> override it with `-p eb-test-stack`.
|
||||
|
||||
---
|
||||
|
||||
## Running the dev stack
|
||||
|
||||
### From Rider (one click)
|
||||
|
||||
The run configuration **DevEnvironment** (`.run/DevEnvironment.run.xml`, checked
|
||||
in) starts the stack with `--build` and force-recreate. It loads the dev overlay
|
||||
on top of the base for you — just press run.
|
||||
|
||||
### From the terminal
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f compose.dev.yaml up --build
|
||||
```
|
||||
|
||||
PocketBase starts first; once its healthcheck passes, the web app starts
|
||||
(`depends_on: service_healthy`). Then:
|
||||
|
||||
- Web app: <http://localhost:5000>
|
||||
- PocketBase admin UI: <http://localhost:8090/_/>
|
||||
- PocketBase health: <http://localhost:8090/api/health>
|
||||
|
||||
> The dev overlay auto-creates a PocketBase superuser (`test@example.com` /
|
||||
> `test-password`) on a **fresh** volume, so you skip the `/_/` setup screen.
|
||||
> These are throwaway local credentials, not the production ones.
|
||||
|
||||
Stop and clean up (removes the throwaway PocketBase volume too):
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f compose.dev.yaml down --volumes
|
||||
```
|
||||
|
||||
If the admin UI already existed once and you want the superuser recreated from
|
||||
scratch, remove the volume first: `docker volume rm eb-stack_pb_data_dev`.
|
||||
|
||||
---
|
||||
|
||||
## Running the tests
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
The integration tests (`PocketBaseFixture` in `Elternbeirat.Web.Tests`) start
|
||||
their **own** PocketBase from the very same compose files — the fixture shells
|
||||
out to:
|
||||
|
||||
```bash
|
||||
docker compose -p eb-test-stack -f compose.yaml -f compose.dev.yaml -f compose.test.yaml up --detach --wait eb-pocketbase
|
||||
```
|
||||
|
||||
so the image version, environment and port are defined in one place, not
|
||||
duplicated in the test code. Only `eb-pocketbase` is started; the web app is not
|
||||
built for the tests.
|
||||
|
||||
Because the test stack has its own project name (`eb-test-stack`), auto-named
|
||||
containers and a **random** host port, it runs happily **alongside** a running
|
||||
dev stack — you can have DevEnvironment up in Rider and run `dotnet test` at the
|
||||
same time. The fixture cleans up before and after each run, so a hard-killed
|
||||
earlier run leaves nothing behind.
|
||||
|
||||
**Requirements:** Docker with the Compose plugin must be available when the tests
|
||||
run (they call `docker compose`).
|
||||
|
||||
---
|
||||
|
||||
## Content while developing
|
||||
|
||||
The app reads its content from PocketBase over the compose network
|
||||
(`PocketBase__BaseUrl=http://eb-pocketbase:8090` — the *service* name, resolved
|
||||
inside the compose network, independent of the container name). Editing content
|
||||
means editing records in the PocketBase admin UI at
|
||||
<http://localhost:8090/_/>, not editing files. See `redaktion.md`.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Editing content
|
||||
|
||||
How to add or change the visitor-facing content of the site. All content lives
|
||||
as files under `Elternbeirat.Web/Content/` — there is no database and no admin
|
||||
interface. Every change needs a **commit** and a **rebuild** of the image, since
|
||||
the content ships inside the image, not in a volume.
|
||||
|
||||
> **Language convention.** File and folder names, front-matter keys and YAML keys
|
||||
> are English (`posts/`, `title:`, `start:`). The text a visitor reads stays
|
||||
> German — that includes the *value* after a key (`title: Vorstandsteam`) and the
|
||||
> Markdown body. So you write English keys with German values.
|
||||
|
||||
The three content kinds:
|
||||
|
||||
| Kind | Where | New entry = |
|
||||
|---|---|---|
|
||||
| Page | `Content/pages/*.md` | one Markdown file |
|
||||
| Post (news) | `Content/posts/*.md` | one Markdown file |
|
||||
| Event (calendar) | `Content/events.yml` | one entry in the list |
|
||||
|
||||
None of these touch a `.razor` file.
|
||||
|
||||
---
|
||||
|
||||
## Pages
|
||||
|
||||
A page is a standalone Markdown file in `Content/pages/`. The file name (without
|
||||
`.md`) is the slug and therefore the URL: `vorstandsteam.md` is served at
|
||||
`/vorstandsteam`.
|
||||
|
||||
Front matter at the top sets the title; the body is the content:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Vorstandsteam
|
||||
---
|
||||
|
||||
# Vorstandsteam
|
||||
|
||||
Hier stellt sich das Team vor …
|
||||
```
|
||||
|
||||
- **Slugs have no umlauts.** Use `ueber-uns`, not `über-uns`; `foerderverein`,
|
||||
not `förderverein`. The visible heading in the body may of course use umlauts.
|
||||
- If you omit `title:`, the slug is used as the title — so always set it.
|
||||
- Add the new page to the navigation only if it should appear there: edit
|
||||
`Components/Layout/MainLayout.razor`. Pages that are only linked from other
|
||||
pages (like the FAQ sub-pages) do not need a nav entry.
|
||||
|
||||
---
|
||||
|
||||
## Posts (news)
|
||||
|
||||
A post is a Markdown file in `Content/posts/`, same idea as a page but with a
|
||||
date. Posts show up in the news list at `/beitraege`, newest first, and each has
|
||||
its own URL at `/beitraege/<slug>`.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Neue Sporthalle feierlich eröffnet
|
||||
date: 2025-10-05
|
||||
---
|
||||
|
||||
# Neue Sporthalle feierlich eröffnet
|
||||
|
||||
Der Text des Beitrags …
|
||||
```
|
||||
|
||||
- `date:` is an ISO date, **`yyyy-MM-dd`**. It drives the sort order (newest
|
||||
first) and the displayed date. A missing or malformed date sorts the post last
|
||||
rather than breaking the build.
|
||||
- The three most recent posts are also teased on the home page automatically —
|
||||
nothing to do there.
|
||||
|
||||
---
|
||||
|
||||
## Events (calendar)
|
||||
|
||||
Events are **not** separate files. They all live in the single list
|
||||
`Content/events.yml`. Add an entry to the list:
|
||||
|
||||
```yaml
|
||||
- title: Elternbeiratssitzung
|
||||
start: "2026-10-08 19:30"
|
||||
location: Lehrerzimmer
|
||||
note: Themen bitte vorab per E-Mail einreichen.
|
||||
```
|
||||
|
||||
Keys:
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `title` | yes | Short name shown in the list and calendar |
|
||||
| `start` | yes | When it starts — see date formats below |
|
||||
| `end` | no | End, for entries that span hours or days |
|
||||
| `location` | no | e.g. `Aula`, `Mensa` |
|
||||
| `note` | no | Free text shown below the entry |
|
||||
|
||||
**Date formats** for `start` and `end`:
|
||||
|
||||
- Date only → all-day event: `"2026-03-15"`
|
||||
- Date with time: `"2026-03-15 19:30"` (24-hour clock)
|
||||
|
||||
Always keep the value in quotes so YAML treats it as text.
|
||||
|
||||
The overview at `/termine` splits the list automatically: entries today or later
|
||||
appear under *Kommende Termine* (earliest first), past ones under *Vergangene
|
||||
Termine* (most recent first). You do not sort the file yourself — order in the
|
||||
YAML does not matter.
|
||||
|
||||
Visitors can subscribe to `/termine.ics` in their own calendar app; that feed is
|
||||
generated from the same file, so a new entry appears there too.
|
||||
|
||||
> A malformed `start` drops just that one entry instead of breaking the whole
|
||||
> page, so a typo in one event will not take the calendar down — but the entry
|
||||
> silently disappears. If an event does not show up, check its `start` value.
|
||||
|
||||
---
|
||||
|
||||
## Publishing a change
|
||||
|
||||
A content change is not live until the image is rebuilt and redeployed:
|
||||
|
||||
1. Edit or add the file under `Content/`.
|
||||
2. Check it locally with `dotnet run --project Elternbeirat.Web`. Content is read
|
||||
once at startup, so after editing a file **restart the process** to see the
|
||||
change (or use `dotnet watch` to restart on save automatically).
|
||||
3. Commit the change.
|
||||
4. Rebuild and redeploy the image — see [deployment](deployment.md).
|
||||
@@ -0,0 +1,122 @@
|
||||
# Redaktion
|
||||
|
||||
Wie die sichtbaren Inhalte der Website gepflegt werden. Der Inhalt liegt in
|
||||
**PocketBase** — einem kleinen Server mit eigenem Admin-Login — und wird dort
|
||||
über die Weboberfläche bearbeitet. Keine Dateien, kein Commit, kein Rebuild: eine
|
||||
Änderung im Admin ist sofort live.
|
||||
|
||||
> **Übergang (Stand #8).** Der Inhalt ist bereits vollständig in PocketBase; die
|
||||
> Blazor-App wird gerade darauf umgestellt, ihn von dort zu lesen (Issue #8).
|
||||
> Solange das läuft, kann die ausgelieferte Seite noch aus den alten Dateien
|
||||
> unter `Content/` stammen. Sobald #8 durch ist, ist PocketBase die einzige
|
||||
> Quelle und dieser Abschnitt der einzige Pflegeweg.
|
||||
|
||||
> **Sprachkonvention.** Feldnamen und Slugs sind **englisch** (`title`, `slug`,
|
||||
> `board`, `posts`). Der Text, den ein Besucher liest, bleibt **deutsch** — also
|
||||
> der Wert eines Feldes (`title: Vorstandsteam`) und der Fließtext. Englische
|
||||
> Schlüssel, deutsche Werte.
|
||||
|
||||
---
|
||||
|
||||
## Anmelden
|
||||
|
||||
Das Admin heißt „Redaktion Elternbeirat" und ist unter der PocketBase-Adresse
|
||||
erreichbar:
|
||||
|
||||
- **Lokal:** <http://localhost:8090/_/> (Dev-Stack, siehe `entwicklung.md`).
|
||||
- **Auf dem Server:** über die NPM-Subdomain (Login von außen — noch offen, #9).
|
||||
|
||||
Redakteure sind **Superuser**: jede vertraute Person aus dem Vorstand bekommt
|
||||
einen eigenen Superuser-Zugang (unter *Collections → System → `_superusers`*).
|
||||
Es gibt bewusst keinen eigenen Login und keinen Editor in der Website selbst —
|
||||
gepflegt wird nur im Admin.
|
||||
|
||||
---
|
||||
|
||||
## Die Inhaltsarten (Collections)
|
||||
|
||||
Jede Inhaltsart ist eine **Collection**. Ein neuer Eintrag = ein neuer Record in
|
||||
der passenden Collection (Button *New record*).
|
||||
|
||||
| Collection | Was | Öffentlich sichtbar über |
|
||||
|---|---|---|
|
||||
| `pages` | Feste Seiten (Vorstand, Impressum, Kontakt …) | Slug, z. B. `/board` |
|
||||
| `posts` | Neuigkeiten / Beiträge | `/posts`, neueste zuerst |
|
||||
| `events` | Termine (Kalender) | `/events` und der `.ics`-Feed |
|
||||
| `faqs` | Häufige Fragen | Werden auf der FAQ-Seite gruppiert angezeigt |
|
||||
|
||||
Jede Collection hat als **letztes Feld `public`** (ja/nein). Nur Records mit
|
||||
`public = true` erscheinen auf der Website — so lässt sich ein Entwurf anlegen,
|
||||
ohne dass er schon sichtbar ist.
|
||||
|
||||
---
|
||||
|
||||
## Seiten (`pages`)
|
||||
|
||||
Eine Seite hat `title` (Überschrift für Menschen, Umlaute erlaubt), `slug` (die
|
||||
URL, klein und **ohne Umlaute**: `board`, nicht `über-uns`) und `body` (der
|
||||
Text, als Editor-Feld).
|
||||
|
||||
- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`,
|
||||
`board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
|
||||
- **Menü:** Ob eine Seite ins Menü kommt, steht an der Seite selbst — die Felder
|
||||
`location` (`header` oder `footer`) und `order` (Reihenfolge, ab 1). Die App
|
||||
baut Kopf- und Fußnavigation daraus; nichts wird im Markup angefasst.
|
||||
- **Eingebettete Blöcke:** Das Feld `embed` (Mehrfachauswahl aus
|
||||
`faqs`/`posts`/`events`) hängt unter den Text der Seite dynamische Blöcke. So
|
||||
ist die Startseite (`home`) eine normale Seite mit `embed = [posts, events]`,
|
||||
und `/faqs` eine Seite mit `embed = [faqs]`. Leeres `embed` = reine Textseite.
|
||||
|
||||
> **Impressum und Datenschutz** hängen an ihren Slugs (`imprint`, `privacy`).
|
||||
> Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins
|
||||
> Leere (404).
|
||||
|
||||
---
|
||||
|
||||
## Beiträge (`posts`)
|
||||
|
||||
Ein Beitrag hat `date`, `title`, `body`, `slug` und `public`. Er erscheint unter
|
||||
`/posts` (neueste zuerst) und unter `/posts/<slug>`; die neuesten werden auch auf
|
||||
der Startseite als Vorschau angeteasert.
|
||||
|
||||
- `date` steuert Sortierung und angezeigtes Datum.
|
||||
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`).
|
||||
|
||||
---
|
||||
|
||||
## Termine (`events`)
|
||||
|
||||
Ein Termin hat `start` (Pflicht), `end` (optional, für mehrstündige oder
|
||||
mehrtägige), `title`, `location` (optional), `note` (optional) und `public`.
|
||||
Kein Slug.
|
||||
|
||||
Die Übersicht `/events` trennt automatisch in kommende und vergangene Termine;
|
||||
die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in
|
||||
ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records.
|
||||
|
||||
> **Uhrzeit = Ortszeit.** Das Admin-Formular rechnet Datumsfelder in die
|
||||
> Browser-Zeitzone um und zeigt eine gespeicherte `19:30` je nach Sommer-/Winter-
|
||||
> zeit als 20:30/21:30 an. Das ist **kein** Fehler, nur zwei Bezugssysteme: der
|
||||
> gespeicherte Zahlenwert **ist** die Ortszeit (Europe/Berlin), die App zeigt ihn
|
||||
> unverändert. Trage die Uhrzeit ein, die auf der Seite stehen soll.
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen (`faqs`)
|
||||
|
||||
Eine Frage hat `question`, `answer` (Markdown), `topic` (eines von
|
||||
`mensa`/`schliessfach`/`elterneuro`/`elternarbeit`) und `public`. Die App baut
|
||||
die FAQ-Seite generiert: sie gruppiert die Fragen nach `topic`. Der Rahmentext
|
||||
oben auf `/faqs` ist eine eigene Seite in `pages` (Slug `faqs`).
|
||||
|
||||
---
|
||||
|
||||
## Regeln beim Speichern
|
||||
|
||||
- **Nach dem Ändern einer Zugriffsregel** immer den Haupt-*Save* der Collection
|
||||
drücken, sonst greift die Änderung nicht.
|
||||
- Die vier Collections sind öffentlich **lesbar** (List/View offen), aber nur
|
||||
eingeloggt **schreibbar** — ein Schreibversuch ohne Login wird abgewiesen. Das
|
||||
ist Absicht; nicht „vereinfachen".
|
||||
- **Backup:** Der gesamte Inhalt liegt in `pb_data`. Ein Backup dieses
|
||||
Verzeichnisses (plus Restore-Test) ist der Sicherungsweg — eingerichtet in #9.
|
||||
Reference in new issue
Block a user