From 2ea8856d65988b9fa74ca9de3109640c2d2bdc4a Mon Sep 17 00:00:00 2001 From: tleininger Date: Tue, 29 Sep 2026 22:11:30 +0200 Subject: [PATCH] Group FAQs by a topic relation, ordered; drop unused FAQ teaser --- Elternbeirat.Contracts/Faq.cs | 44 ++++-- Elternbeirat.Contracts/FaqTopic.cs | 73 +++++++++ Elternbeirat.Contracts/Page.cs | 4 +- Elternbeirat.PocketBase/PocketBaseClient.cs | 30 ++-- .../PocketBaseClientTests.cs | 21 +-- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 34 +++- .../Features/Embeds/FaqsEmbed.razor | 16 -- .../Features/Embeds/FaqsEmbed.razor.cs | 46 ------ .../Features/Embeds/FaqsEmbed.razor.css | 5 - .../Features/Embeds/PageEmbeds.razor | 5 +- Elternbeirat.Web/Features/Faqs/FaqList.razor | 2 +- .../Features/Faqs/FaqList.razor.cs | 9 +- pb/pb_migrations/1727200000_dev_seed.js | 19 ++- pb/pb_migrations/collections_schema.json | 148 +++++++++++++++--- 14 files changed, 328 insertions(+), 128 deletions(-) create mode 100644 Elternbeirat.Contracts/FaqTopic.cs delete mode 100644 Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor delete mode 100644 Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs delete mode 100644 Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css 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) -{ -
-

Häufige Fragen

- @foreach (var faq in _faqs) - { -
- @faq.Question -
- @((MarkupString)Markdown.ToHtml(faq.Answer)) -
-
- } -

Alle Fragen

-
-} 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": [],