diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs
index c7b22ca..f592cb3 100644
--- a/Elternbeirat.Contracts/Faq.cs
+++ b/Elternbeirat.Contracts/Faq.cs
@@ -6,7 +6,9 @@ namespace Elternbeirat.Contracts;
/// A single question and answer of the FAQ, as stored in PocketBase.
///
///
-/// On the FAQ page, entries are grouped by .
+/// On the FAQ page, entries are grouped by their . The topic is
+/// a PocketBase relation; is the resolved topic record, present
+/// only when the query expanded the relation (see ).
///
public record Faq
{
@@ -42,17 +44,39 @@ public record Faq
public required string Answer { get; init; }
///
- /// Gets the topic the question is grouped under, e.g.
- /// "Mensa und Mittagessen".
+ /// Gets the expanded relations returned by PocketBase, or
+ /// when the query did not expand any.
+ ///
+ [JsonPropertyName("expand")]
+ public FaqExpand? Expand { get; init; }
+
+ ///
+ /// Gets the resolved topic this entry is grouped under, or
+ /// when the query did not expand the topic relation.
///
///
- /// : the FAQ page groups entries by topic, so an entry
- /// without one has no place. The field is required in PocketBase.
- ///
- /// This is the heading shown on the page verbatim, not a slug: editors add a
- /// new group by typing its heading here, no code change. Entries with the same
- /// topic text land in one group.
+ /// Convenience over : the FAQ page reads the grouping
+ /// heading from here. It is unless the entry was loaded
+ /// with the topic relation expanded.
///
+ [JsonIgnore]
+ public FaqTopic? Topic => Expand?.Topic;
+}
+
+///
+/// The relations of a that PocketBase returns under
+/// expand when the query asks for them.
+///
+///
+/// One property per expandable relation, named after its PocketBase field, so the
+/// JSON expand object maps straight onto it. reads
+/// through this.
+///
+public record FaqExpand
+{
+ ///
+ /// Gets the expanded topic of the topic relation, if it was expanded.
+ ///
[JsonPropertyName("topic")]
- public required string Topic { get; init; }
+ public FaqTopic? Topic { get; init; }
}
diff --git a/Elternbeirat.Contracts/FaqTopic.cs b/Elternbeirat.Contracts/FaqTopic.cs
new file mode 100644
index 0000000..9f681bb
--- /dev/null
+++ b/Elternbeirat.Contracts/FaqTopic.cs
@@ -0,0 +1,73 @@
+using System.Text.Json.Serialization;
+
+namespace Elternbeirat.Contracts;
+
+///
+/// A topic the FAQ entries are grouped under, as stored in PocketBase.
+///
+///
+/// A points at its topic through a relation, so the FAQ page
+/// groups the questions by topic and shows the groups in .
+/// Making the topic its own record (rather than a free-text field on each
+/// question) means the heading, its order and its optional
+/// are edited in one place and shared by every question in the group.
+///
+public record FaqTopic
+{
+ ///
+ /// 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 required string Id { get; init; }
+
+ ///
+ /// Gets the heading shown above the group of questions, e.g.
+ /// "Mensa und Mittagessen".
+ ///
+ ///
+ /// Free text; may contain umlauts and spaces. For the URL, see .
+ /// : a topic without a heading has nothing to show, and
+ /// the field is required in PocketBase.
+ ///
+ [JsonPropertyName("title")]
+ public required string Title { get; init; }
+
+ ///
+ /// Gets the URL slug of the topic, e.g. "mensa".
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// : the slug is required in PocketBase and is the
+ /// stable identifier of a topic, independent of its display title.
+ ///
+ [JsonPropertyName("slug")]
+ public required string Slug { get; init; }
+
+ ///
+ /// Gets the optional introduction shown below the topic heading, as Markdown.
+ ///
+ ///
+ /// The intro Markdown, or an empty string when the topic has none.
+ ///
+ [JsonPropertyName("intro")]
+ public string Intro { get; init; } = string.Empty;
+
+ ///
+ /// Gets the sort order of the topic among the FAQ groups.
+ ///
+ ///
+ /// The sort key; topics with smaller values appear first.
+ ///
+ [JsonPropertyName("order")]
+ public double Order { get; init; }
+}
diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs
index ee17931..4fb6dfc 100644
--- a/Elternbeirat.Contracts/Page.cs
+++ b/Elternbeirat.Contracts/Page.cs
@@ -90,8 +90,8 @@ public record Page
/// Gets the dynamic blocks rendered below the .
///
///
- /// Any of "posts", "events" and "faqs", or an empty list
- /// for a plain text page.
+ /// Any of "posts" and "events", or an empty list for a plain
+ /// text page.
///
[JsonPropertyName("embed")]
public IReadOnlyList Embed { get; init; } = [];
diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs
index 23e13f4..fd9507b 100644
--- a/Elternbeirat.PocketBase/PocketBaseClient.cs
+++ b/Elternbeirat.PocketBase/PocketBaseClient.cs
@@ -70,15 +70,17 @@ public sealed class PocketBaseClient(HttpClient httpClient)
=> GetRecordsAsync("events", "start", token);
///
- /// Gets all public FAQ entries, ordered by , then by
- /// the editor's order within each topic.
+ /// Gets all public FAQ entries, grouped by topic: ordered by the topic's own
+ /// order, then by the entry's order within that topic. Each entry
+ /// carries its expanded .
///
///
- /// The second sort key makes the order total: sorting by topic alone leaves
- /// entries within a topic in an undefined order, so the same content could
- /// render differently between requests. The per-topic order field lets
- /// an editor arrange the questions within a topic and keeps that arrangement
- /// stable between requests; the topic groups themselves stay alphabetical.
+ /// Sorting by topic.order first keeps all questions of a topic together
+ /// and lists the topic groups in the order an editor gave the topics; the
+ /// entry's own order then arranges the questions within each group. Both
+ /// keys together make the order total, so the same content renders the same way
+ /// between requests. The topic relation is expanded so the page can show each
+ /// group's heading without a second request.
///
///
///
@@ -86,7 +88,7 @@ public sealed class PocketBaseClient(HttpClient httpClient)
///
///
public Task> GetFaqsAsync(CancellationToken token = default)
- => GetRecordsAsync("faqs", "topic,order", token);
+ => GetRecordsAsync("faqs", "topic.order,order", token, expand: "topic");
///
/// Gets all public records of a collection in a single request.
@@ -103,6 +105,10 @@ public sealed class PocketBaseClient(HttpClient httpClient)
///
/// A token to cancel the request.
///
+ ///
+ /// A PocketBase expand expression naming relations to resolve inline
+ /// (comma-separated, dot-nested), or to expand none.
+ ///
///
/// The public records, or an empty list if there are none.
///
@@ -115,7 +121,8 @@ public sealed class PocketBaseClient(HttpClient httpClient)
private async Task> GetRecordsAsync(
string collection,
string sort,
- CancellationToken token)
+ CancellationToken token,
+ string? expand = null)
{
// filter=public=true keeps drafts out; perPage is large enough to fetch
// every record in a single page given the small content volume.
@@ -123,6 +130,11 @@ public sealed class PocketBaseClient(HttpClient httpClient)
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}";
+ // Add expand only when asked, so callers that need no relations send the
+ // leaner request they did before.
+ if (expand is not null)
+ url += $"&expand={Uri.EscapeDataString(expand)}";
+
try
{
var result = await httpClient.GetFromJsonAsync>(url, token);
diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs
index 59b1305..f875e29 100644
--- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs
+++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs
@@ -18,7 +18,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var events = await client.GetEventsAsync();
// Two seeded events, earliest start first.
- events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
+ events.Select(@event => @event.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
}
[Fact]
@@ -46,8 +46,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var events = await client.GetEventsAsync();
- var meeting = events.Single(e => e.Title == "Elternbeiratssitzung");
- var basar = events.Single(e => e.Title == "Herbstbasar");
+ var meeting = events.Single(@event => @event.Title == "Elternbeiratssitzung");
+ var basar = events.Single(@event => @event.Title == "Herbstbasar");
meeting.End.ShouldBeNull(); // no end seeded
basar.End.ShouldNotBeNull(); // end seeded
}
@@ -60,7 +60,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var posts = await client.GetPostsAsync();
// Sorted by -date: March before January.
- posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
+ posts.Select(post => post.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
}
[Fact]
@@ -85,9 +85,9 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var pages = await client.GetPagesAsync();
// The draft (public=false) must be filtered out, the public ones present.
- pages.ShouldNotContain(p => p.Slug == "draft");
- pages.ShouldContain(p => p.Slug == "home");
- pages.ShouldContain(p => p.Slug == "contact");
+ pages.ShouldNotContain(page => page.Slug == "draft");
+ pages.ShouldContain(page => page.Slug == "home");
+ pages.ShouldContain(page => page.Slug == "contact");
}
[Fact]
@@ -111,7 +111,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var faqs = await client.GetFaqsAsync();
- faqs.Select(f => f.Topic).ShouldBe(["Mensa und Mittagessen", "Schließfächer"], ignoreOrder: true);
- faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question));
+ // The topic relation is expanded, so each entry carries its topic. The two
+ // seeded topics are ordered (Mensa order 1 before Schließfächer order 2), and
+ // GetFaqsAsync sorts by topic.order, so the groups come back in that order.
+ faqs.Select(faq => faq.Topic?.Title).ShouldBe(["Mensa und Mittagessen", "Schließfächer"]);
+ faqs.ShouldAllBe(faq => !string.IsNullOrWhiteSpace(faq.Question));
}
}
diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
index 6d58445..439f3e2 100644
--- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs
+++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
@@ -313,23 +313,49 @@ public sealed class PocketBaseFixture : IAsyncLifetime
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
});
- // Faqs: two topics.
+ // Faq topics: two, ordered. The topics own the grouping and the order the FAQ
+ // page shows the groups in (Mensa before Schließfächer).
+ var lunchTopicId = await CreateRecordAsync(http, "faq_topics", new
+ {
+ title = "Mensa und Mittagessen", slug = "mensa", intro = "",
+ order = 1, @public = true,
+ });
+ var lockerTopicId = await CreateRecordAsync(http, "faq_topics", new
+ {
+ title = "Schließfächer", slug = "schliessfach", intro = "",
+ order = 2, @public = true,
+ });
+
+ // Faqs: each points at its topic through the "topic" relation (the created
+ // topic id), and carries its own order within the topic.
await CreateRecordAsync(http, "faqs", new
{
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
- topic = "Mensa und Mittagessen", @public = true,
+ topic = lunchTopicId, order = 1, @public = true,
});
await CreateRecordAsync(http, "faqs", new
{
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
- topic = "Schließfächer", @public = true,
+ topic = lockerTopicId, order = 1, @public = true,
});
}
- private static async Task CreateRecordAsync(HttpClient http, string collection, object record)
+ private static async Task CreateRecordAsync(HttpClient http, string collection, object record)
{
var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record);
response.EnsureSuccessStatusCode();
+
+ // Return the created record's id so a caller can seed a relation to it (e.g. a
+ // faq pointing at its topic). Callers that do not need it just ignore the value.
+ var created = await response.Content.ReadFromJsonAsync();
+ return created?.Id ?? throw new InvalidOperationException(
+ $"PocketBase returned no id for the created '{collection}' record.");
+ }
+
+ private sealed record CreatedRecord
+ {
+ [System.Text.Json.Serialization.JsonPropertyName("id")]
+ public string Id { get; init; } = string.Empty;
}
private sealed record AuthResponse
diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor
deleted file mode 100644
index 893d725..0000000
--- a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor
+++ /dev/null
@@ -1,16 +0,0 @@
-@if (_faqs.Count > 0)
-{
-
-
-
-}
diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs
deleted file mode 100644
index 5d421ba..0000000
--- a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using Elternbeirat.Contracts;
-using Elternbeirat.PocketBase;
-using Microsoft.AspNetCore.Components;
-
-namespace Elternbeirat.Web.Features.Embeds;
-
-public partial class FaqsEmbed
-{
- // Number of questions shown in the teaser; the full hub is one link away.
- private const int TeaserCount = 3;
-
- [LoggerMessage(
- Level = LogLevel.Warning,
- Message = "Could not load FAQs for an embed from PocketBase; the teaser is omitted.")]
- private static partial void LogSourceUnavailable(
- ILogger logger,
- Exception exception);
-
- [Inject]
- private PocketBaseClient PocketBase { get; set; } = null!;
-
- [Inject]
- private ILogger Logger { get; set; } = null!;
-
- [CascadingParameter]
- private HttpContext? HttpContext { get; set; }
-
- private IReadOnlyList _faqs = [];
-
- protected override async Task OnInitializedAsync()
- {
- try
- {
- // GetFaqsAsync is ordered by topic, then question, so the first entries
- // the teaser takes are stable across requests.
- var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
- _faqs = [.. faqs.Take(TeaserCount)];
- }
- catch (PocketBaseUnavailableException exception)
- {
- // Only part of the page fails: omit the teaser, keep the page (and its
- // 200) intact. A missing teaser is degraded, not broken.
- LogSourceUnavailable(Logger, exception);
- }
- }
-}
diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css
deleted file mode 100644
index 4a084e8..0000000
--- a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css
+++ /dev/null
@@ -1,5 +0,0 @@
-/* Card styles for .faq-item are shared with the full FAQ page and live in the
- global app.css. Only the embed's spacing on the home page is specific here. */
-.embed-faqs {
- margin-top: var(--space-5);
-}
diff --git a/Elternbeirat.Web/Features/Embeds/PageEmbeds.razor b/Elternbeirat.Web/Features/Embeds/PageEmbeds.razor
index 60f3263..332f120 100644
--- a/Elternbeirat.Web/Features/Embeds/PageEmbeds.razor
+++ b/Elternbeirat.Web/Features/Embeds/PageEmbeds.razor
@@ -12,14 +12,11 @@
case "events":
break;
- case "faqs":
-
- break;
}
}
@code {
- /// The page's embed keys ("posts", "events", "faqs"), in order.
+ /// The page's embed keys ("posts", "events"), in order.
[Parameter]
public IReadOnlyList Embed { get; set; } = [];
}
diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor b/Elternbeirat.Web/Features/Faqs/FaqList.razor
index c1d9d53..21488dc 100644
--- a/Elternbeirat.Web/Features/Faqs/FaqList.razor
+++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor
@@ -17,7 +17,7 @@ else
@foreach (var group in _groups)
{
-
@group.Key
+
@group.Key.Title
@foreach (var faq in group)
{
diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs
index f2e1802..68362fc 100644
--- a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs
+++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs
@@ -23,7 +23,7 @@ public partial class FaqList
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
- private IReadOnlyList> _groups = [];
+ private IReadOnlyList> _groups = [];
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of "no questions", matching
@@ -35,7 +35,12 @@ public partial class FaqList
try
{
var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
- _groups = [.. faqs.GroupBy(faq => faq.Topic)];
+
+ // GetFaqsAsync sorts by topic.order first, so grouping in encounter order
+ // keeps the topic groups in the order an editor gave the topics. Entries
+ // without an expanded topic have no group and are skipped rather than
+ // grouped under a null heading.
+ _groups = [.. faqs.Where(faq => faq.Topic is not null).GroupBy(faq => faq.Topic!)];
}
catch (PocketBaseUnavailableException exception)
{
diff --git a/pb/pb_migrations/1727200000_dev_seed.js b/pb/pb_migrations/1727200000_dev_seed.js
index 3477406..0f2d451 100644
--- a/pb/pb_migrations/1727200000_dev_seed.js
+++ b/pb/pb_migrations/1727200000_dev_seed.js
@@ -27,11 +27,19 @@ migrate((app) => {
,
events: [{"end":"","location":"Aula","note":"Gäste sind willkommen. Um Anmeldung im Sekretariat wird gebeten.","public":true,"start":"2026-10-08 19:30:00.000Z","title":"Elternbeiratssitzung"},{"end":"2026-10-20 18:30:00.000Z","location":"Klassenräume","note":"Terminvergabe über das Schulportal.","public":true,"start":"2026-10-20 16:00:00.000Z","title":"Elternsprechtag"},{"end":"2026-11-22 13:00:00.000Z","location":"Schulhof","note":"Kuchenspenden willkommen.","public":true,"start":"2026-11-22 09:00:00.000Z","title":"Herbstbasar"},{"end":"2027-01-06 00:00:00.000Z","location":"","note":"","public":true,"start":"2026-12-19 00:00:00.000Z","title":"Weihnachtsferien"},{"end":"","location":"Mensa","note":"Kennenlernen bei Butterbrezeln und Kaffee.","public":true,"start":"2026-09-10 18:00:00.000Z","title":"Willkommensabend der neuen Fünftklässler"}]
,
- faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"Mensa und Mittagessen"},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"Mensa und Mittagessen"},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"Mensa und Mittagessen"},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"Schließfächer"},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"Schließfächer"},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"Schließfächer"},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"Eltern-Euro"},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"Eltern-Euro"},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"Elternarbeit und Mitmachen"},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"Elternarbeit und Mitmachen"}]
+ // Topics own the grouping and the order of the FAQ page; a question links to
+ // its topic below via the "topic" relation, resolved by slug at seed time.
+ faq_topics: [{"title":"Elternarbeit","slug":"elternarbeit","intro":"","order":1,"public":true},{"title":"Eltern-Euro","slug":"eltern-euro","intro":"","order":2,"public":true},{"title":"Mensa und Mittagessen","slug":"mensa","intro":"","order":3,"public":true},{"title":"Schließfächer","slug":"schliessfach","intro":"","order":4,"public":true}]
+,
+ // "topic" is a slug here, not a relation id: the record loader below resolves it
+ // to the faq_topics id, since the ids are only known once the topics are saved.
+ faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"mensa","order":1},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"mensa","order":2},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"mensa","order":3},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"schliessfach","order":1},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"schliessfach","order":2},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"schliessfach","order":3},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"eltern-euro","order":1},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"eltern-euro","order":2},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"elternarbeit","order":1},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"elternarbeit","order":2}]
,
}
- // Phase 1: schema. Create any collection that is not present yet.
+ // Phase 1: schema. Create any collection that is not present yet. The schema
+ // file lists faq_topics before faqs, so the target of the faqs "topic" relation
+ // exists by the time faqs is created.
for (const name of Object.keys(schema)) {
try {
app.findCollectionByNameOrId(name)
@@ -42,13 +50,20 @@ migrate((app) => {
// Phase 2: records. Fill a collection only while it is still empty, so a
// populated volume (or, should this ever run there, production) is untouched.
+ // faqs are seeded after faq_topics (object key order), so the slug->id map is
+ // populated before a faq resolves its "topic" against it.
+ const faqTopicIdBySlug = {}
for (const [name, rows] of Object.entries(records)) {
if (app.countRecords(name) > 0) continue
const collection = app.findCollectionByNameOrId(name)
for (const data of rows) {
+ // A faq's "topic" is a topic slug in this seed; swap it for the real id.
+ if (name === "faqs") data = { ...data, topic: faqTopicIdBySlug[data.topic] }
const record = new Record(collection)
record.load(data)
app.save(record)
+ // Remember each topic's id so the faqs seeded afterwards can point at it.
+ if (name === "faq_topics") faqTopicIdBySlug[data.slug] = record.id
}
}
}, (app) => {
diff --git a/pb/pb_migrations/collections_schema.json b/pb/pb_migrations/collections_schema.json
index db11602..fc0ec7c 100644
--- a/pb/pb_migrations/collections_schema.json
+++ b/pb/pb_migrations/collections_schema.json
@@ -309,7 +309,7 @@
"help": "",
"hidden": false,
"id": "select1203373331",
- "maxSelect": 3,
+ "maxSelect": 2,
"name": "embed",
"presentable": false,
"required": false,
@@ -317,8 +317,7 @@
"type": "select",
"values": [
"posts",
- "events",
- "faqs"
+ "events"
]
},
{
@@ -337,6 +336,107 @@
"updated": "2026-09-23 11:25:35.648Z",
"system": false
},
+ "faq_topics": {
+ "id": "pbc_529028860",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "faq_topics",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "help": "",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text724990059",
+ "max": 0,
+ "min": 0,
+ "name": "title",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text2560465762",
+ "max": 0,
+ "min": 0,
+ "name": "slug",
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text436585760",
+ "max": 0,
+ "min": 0,
+ "name": "intro",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "number4113142680",
+ "max": null,
+ "min": null,
+ "name": "order",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "bool1001664029",
+ "name": "public",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ }
+ ],
+ "indexes": [
+ "CREATE UNIQUE INDEX `idx_faq_topics_slug` ON `faq_topics` (`slug`)"
+ ],
+ "created": "2026-09-23 11:59:13.899Z",
+ "updated": "2026-09-23 11:59:13.899Z",
+ "system": false
+ },
"faqs": {
"id": "pbc_2923026773",
"listRule": "",
@@ -392,21 +492,6 @@
"system": false,
"type": "text"
},
- {
- "autogeneratePattern": "",
- "help": "",
- "hidden": false,
- "id": "select2638274075",
- "max": 0,
- "min": 0,
- "name": "topic",
- "pattern": "",
- "presentable": false,
- "primaryKey": false,
- "required": true,
- "system": false,
- "type": "text"
- },
{
"help": "",
"hidden": false,
@@ -416,6 +501,33 @@
"required": false,
"system": false,
"type": "bool"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_529028860",
+ "help": "",
+ "hidden": false,
+ "id": "relation3340346911",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "topic",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "number4113142680",
+ "max": null,
+ "min": null,
+ "name": "order",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
}
],
"indexes": [],