diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
index 1160e7b..21ba87e 100644
--- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs
+++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
@@ -2,6 +2,8 @@ using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Headers;
using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
using Elternbeirat.PocketBase;
namespace Elternbeirat.Web.Tests;
@@ -16,9 +18,16 @@ namespace Elternbeirat.Web.Tests;
/// (only the eb-pocketbase service, not the web app) by shelling out to
/// docker compose, 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.
+/// PocketBase the stack runs.
+///
+///
+/// The collection schema is not typed out here: it is imported from
+/// pb/pb_migrations/collections_schema.json, the same file the dev seed
+/// migration uses, so the schema is defined once and cannot drift between the two.
+/// The seeded records, by contrast, are fixed in this file on purpose (not
+/// the dev seed's data), so tests assert against values this file controls -- e.g.
+/// a non-public draft page that must never appear, and an event at a known time.
+/// Requires Docker with the Compose plugin.
///
///
public sealed class PocketBaseFixture : IAsyncLifetime
@@ -145,15 +154,15 @@ public sealed class PocketBaseFixture : IAsyncLifetime
}
///
- /// Authenticates as superuser, then creates the collections and records. This
- /// mirrors the shape the client reads, not the full production schema.
+ /// Authenticates as superuser, then imports the shared collection schema and
+ /// seeds the fixed test records the tests assert against.
///
private async Task SeedAsync()
{
var token = await AuthenticateAsync(_http);
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
- await CreateCollectionsAsync(_http);
+ await ImportCollectionsAsync(_http);
await SeedRecordsAsync(_http);
// Drop the superuser token so the tests read as an anonymous visitor would,
@@ -184,59 +193,27 @@ public sealed class PocketBaseFixture : IAsyncLifetime
}
}
- private static async Task CreateCollectionsAsync(HttpClient http)
+ ///
+ /// Imports the four content collections from the shared schema file, the same
+ /// one the dev seed migration reads (pb/pb_migrations/collections_schema.json).
+ /// Uses PocketBase's /api/collections/import so the whole schema is
+ /// defined in one place instead of being typed out here.
+ ///
+ private static async Task ImportCollectionsAsync(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 = "text" },
- 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" },
- });
+ // The file maps name -> collection object; the import endpoint wants a
+ // flat list, so unwrap the values. deleteMissing=false leaves anything
+ // already present untouched (the import is idempotent).
+ var path = Path.Combine(RepoRoot(), "pb", "pb_migrations", "collections_schema.json");
+ using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path));
+ var collections = schema.RootElement.EnumerateObject()
+ .Select(property => property.Value)
+ .ToArray();
- await CreateCollectionAsync(http, "posts", new object[]
- {
- new { name = "date", type = "date" },
- new { name = "title", type = "text", required = true },
- new { name = "body", type = "text" },
- 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 = "text" },
- 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,
- });
+ // Serialize the JsonElement list back to JSON for the request body.
+ var body = JsonSerializer.Serialize(new { collections, deleteMissing = false });
+ using var content = new StringContent(body, Encoding.UTF8, "application/json");
+ var response = await http.PutAsync(new Uri("/api/collections/import", UriKind.Relative), content);
response.EnsureSuccessStatusCode();
}
@@ -277,7 +254,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime
await CreateRecordAsync(http, "pages", new
{
title = "FAQ Mensa", body = "# Mensa", location = "",
- order = 0, slug = "faq-mensa", embed = Array.Empty(), @public = true,
+ order = 0, slug = "faq-lunch", embed = Array.Empty(), @public = true,
});
await CreateRecordAsync(http, "pages", new
{
diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
index c5fd147..821951b 100644
--- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs
+++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
@@ -49,7 +49,7 @@ public sealed class RouteSmokeTests : IDisposable
"/board",
"/patrons",
"/faqs",
- "/faq-mensa",
+ "/faq-lunch",
"/downloads",
"/contact",
"/imprint",
diff --git a/compose.test.yaml b/compose.test.yaml
index 388c195..1f94e42 100644
--- a/compose.test.yaml
+++ b/compose.test.yaml
@@ -18,6 +18,14 @@ services:
# lets Docker pick a random free one; the tests read it back with `compose port`.
ports: !override
- "8090"
+ # Drop the dev overlay's pb_migrations bind mount. The tests must NOT run the
+ # dev seed -- it would insert the dev data set, which the tests do not assert
+ # against (and which lacks the fixtures' draft page, known event time, etc.).
+ # The fixture creates the collections itself by importing the shared schema
+ # file, then seeds its own fixed records. !override replaces the inherited
+ # volumes list; the throwaway pb_data volume is re-declared so it survives.
+ volumes: !override
+ - pb_data_dev:/pb_data
eb-web:
container_name: !reset null # see eb-pocketbase above (eb-web is not started in tests)
diff --git a/docs/entwicklung.md b/docs/entwicklung.md
index f4783db..e2455e5 100644
--- a/docs/entwicklung.md
+++ b/docs/entwicklung.md
@@ -17,8 +17,8 @@ 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. |
+| `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, seeds a local PocketBase superuser, and mounts `pb/pb_migrations` so a fresh volume is auto-seeded (see below). |
+| `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, and removes the seed mount so the dev seed does not run during tests. |
> **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`
@@ -88,11 +88,60 @@ dev stack — you can have DevEnvironment up in Rider and run `dotnet test` at t
same time. The fixture cleans up before and after each run, so a hard-killed
earlier run leaves nothing behind.
+The fixture creates the collections by importing the **same** schema file the dev
+seed uses (see the next section), so the schema is defined once. Its *records*,
+though, are fixed in `PocketBaseFixture` on purpose — the tests assert against
+values that file controls (a non-public draft page that must never show, an event
+at a known time), not against the dev seed's content. The test overlay therefore
+drops the `pb_migrations` mount so the dev seed does **not** run during tests.
+
**Requirements:** Docker with the Compose plugin must be available when the tests
run (they call `docker compose`).
---
+## Example content on a fresh volume (the dev seed)
+
+A fresh dev volume comes up populated, so a first `docker compose … up` shows a
+working site instead of an empty one. Two files under `pb/pb_migrations/` do this,
+and the dev overlay mounts that folder into PocketBase (`./pb/pb_migrations:/pb_migrations`):
+
+| File | Role |
+|---|---|
+| `collections_schema.json` | The four content collections (`pages`, `posts`, `events`, `faqs`) as PocketBase exports them. **The single source of truth for the schema** — the test fixture imports this same file. |
+| `_dev_seed.js` | The PocketBase migration. Reads the schema file, creates any missing collection, then inserts the example records. |
+
+Both steps are **idempotent**: a collection is created only if it is missing, and
+filled only while it is empty. So a populated volume — or the production DB, should
+this ever run there — is never touched. To start over from an empty, then freshly
+seeded volume:
+
+```bash
+docker volume rm eb-stack_pb_data_dev
+docker compose -f compose.yaml -f compose.dev.yaml up --build
+```
+
+> **Seed only in dev.** The `pb_migrations` mount lives in `compose.dev.yaml`
+> alone. The base (production) file does not mount it, and the test overlay
+> removes it again — production content is seeded by hand, never from this repo.
+
+### Refreshing the seed after a schema or content change
+
+The seed is generated from a running dev instance, not hand-edited. When you
+change a collection's schema in the admin UI, or want to capture the current
+example content as the new seed, re-export from the running dev PocketBase
+(authenticated as the dev superuser) and regenerate the two files. The schema file
+is the plain export of the four collections; the migration embeds the records the
+same way. After regenerating, prove it end to end:
+
+```bash
+docker volume rm eb-stack_pb_data_dev # force a fresh volume
+docker compose -f compose.yaml -f compose.dev.yaml up --build
+dotnet test # fixture imports the new schema
+```
+
+---
+
## Content while developing
The app reads its content from PocketBase over the compose network
@@ -126,6 +175,12 @@ It takes three steps, all small because the plumbing is shared:
`EventList`, `FaqList`) for the load-in-`OnInitializedAsync`, log-and-degrade
pattern, and add a route smoke test plus a fixture seed.
+Also refresh the seed so the new collection comes up on a fresh volume and in the
+tests: re-export it into `pb/pb_migrations/collections_schema.json` (the shared
+schema) and add example rows to the dev seed migration — see *Refreshing the seed*
+above. The test fixture picks the new schema up automatically once it is in the
+file.
+
Text pages need none of this — they are all served generically by
`ContentPage` (`@page "/{Slug}"`), which is why a new `pages` record is live
without a rebuild.
diff --git a/pb/pb_migrations/1727200000_dev_seed.js b/pb/pb_migrations/1727200000_dev_seed.js
index 5212000..14e0548 100644
--- a/pb/pb_migrations/1727200000_dev_seed.js
+++ b/pb/pb_migrations/1727200000_dev_seed.js
@@ -5,16 +5,20 @@
// `docker compose up`. Mounted into the container ONLY by compose.dev.yaml (bind
// `./pb/pb_migrations:/pb_migrations`), so it never runs against production.
//
+// The collection SCHEMA is not embedded here -- it is read from
+// collections_schema.json in this same folder, which is the single source of
+// truth for the schema. The test fixture (Elternbeirat.Web.Tests) imports that
+// very same file, so the schema is defined once, not typed twice.
+//
// Two guarded phases, both idempotent:
// 1. Schema -- a collection is created only if it does not exist yet.
// 2. Records -- a collection is filled only while it is still empty.
// So editing content locally, or pointing this at a populated volume, is safe:
// existing collections and existing data are left untouched.
-//
-// Generated from the local dev database. To refresh the seed, re-export and
-// regenerate this file rather than hand-editing the schema blocks.
migrate((app) => {
- const schema = {"posts":{"id":"pbc_1125843985","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"posts","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"},{"help":"","hidden":false,"id":"date2862495610","max":"","min":"","name":"date","presentable":false,"required":false,"system":false,"type":"date"},{"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":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.035Z","updated":"2026-09-23 11:25:30.698Z","system":false},"events":{"id":"pbc_1687431684","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"events","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"},{"help":"","hidden":false,"id":"date2675529103","max":"","min":"","name":"start","presentable":false,"required":true,"system":false,"type":"date"},{"help":"","hidden":false,"id":"date16528305","max":"","min":"","name":"end","presentable":false,"required":false,"system":false,"type":"date"},{"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":"text1587448267","max":0,"min":0,"name":"location","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3485334036","max":0,"min":0,"name":"note","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.110Z","updated":"2026-09-23 10:38:12.110Z","system":false},"pages":{"id":"pbc_3945946014","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"pages","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":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1587448267","maxSelect":1,"name":"location","presentable":false,"required":false,"system":false,"type":"select","values":["header","footer"]},{"help":"","hidden":false,"id":"number4113142680","max":null,"min":null,"name":"order","onlyInt":false,"presentable":false,"required":false,"system":false,"type":"number"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1203373331","maxSelect":3,"name":"embed","presentable":false,"required":false,"system":false,"type":"select","values":["posts","events","faqs"]},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 11:25:35.648Z","updated":"2026-09-23 11:25:35.648Z","system":false},"faqs":{"id":"pbc_2923026773","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"faqs","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":"text3069659470","max":0,"min":0,"name":"question","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3671935525","max":0,"min":0,"name":"answer","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select2638274075","maxSelect":1,"name":"topic","presentable":false,"required":false,"system":false,"type":"select","values":["mensa","schliessfach","elterneuro","elternarbeit"]},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 11:59:13.899Z","updated":"2026-09-23 11:59:13.899Z","system":false}}
+ // The schema file sits next to this migration. In this image the migrations
+ // dir is mounted at /pb_migrations (see compose.dev.yaml).
+ const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
const records = {
pages: [{"body":"# Vorstandsteam\n\nDer Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\n## Vorstand\n\n- **Vorsitz** — Anke Berger\n- **Stellvertretung** — Murat Yılmaz\n- **Kasse** — Sabine Krause\n- **Schriftführung** — Thomas Heinrich\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen — die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"# Förderverein\n\nDer Förderverein der IGMH unterstützt Projekte, für die im regulären Schulhaushalt kein Geld vorgesehen ist: von der Ausstattung der Bibliothek über Pausenspielgeräte bis zu Zuschüssen für Klassenfahrten, damit kein Kind aus finanziellen Gründen zurückbleibt.\n\n## Mitglied werden\n\nDer Jahresbeitrag beträgt 24 € — jeder Euro fließt direkt in die Arbeit an der Schule. Den Aufnahmeantrag finden Sie unter [Downloads](/downloads).\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Sprechen Sie uns über die [Kontaktseite](/contact) an.","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"# Häufige Fragen\n\nHier beantworten wir die Fragen, die uns am häufigsten erreichen. Ausführliche Antworten gibt es auf den Unterseiten:\n\n- [Mensa und Mittagessen](/faq-mensa)\n- [Schließfächer](/faq-schliessfach)\n- [Eltern-Euro](/faq-elterneuro)\n- [Elternarbeit und Mitmachen](/faq-elternarbeit)\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact).","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQ"},{"body":"# Downloads\n\nFormulare und Dokumente zum Herunterladen:\n\n- Aufnahmeantrag Förderverein\n- Einzugsermächtigung\n- Erstattungsformular Klassenkasse\n\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"# Kontakt\n\nSie erreichen den Elternbeirat der IGMH per E-Mail:\n\n**elternbeirat@igmh.example**\n\nWir antworten in der Regel innerhalb weniger Tage. Bei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der Schule.","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MStV: der Vorstand des Elternbeirats.","embed":[],"location":"footer","order":2,"public":true,"slug":"imprint","title":"Impressum"},{"body":"# Datenschutz\n\nDer Schutz Ihrer Daten ist uns wichtig. Diese Website kommt ohne externe Dienste aus: keine Analysewerkzeuge, keine eingebundenen Karten oder Videos, keine Schriften von fremden Servern.\n\n## Server-Logs\n\nBeim Aufruf werden technisch notwendige Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite) kurzzeitig verarbeitet, um den Betrieb sicherzustellen. Eine Zusammenführung mit anderen Daten findet nicht statt.\n\n## Ihre Rechte\n\nFragen zum Datenschutz richten Sie bitte an die [Kontaktseite](/contact).","embed":[],"location":"footer","order":3,"public":true,"slug":"privacy","title":"Datenschutz"},{"body":"# Mensa und Mittagessen\n\n## Wie melde ich mein Kind zum Mittagessen an?\n\nDie Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.\n\n## Was kostet ein Essen?\n\nEin 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.\n\n## Gibt es vegetarische Gerichte?\n\nJa, täglich steht mindestens ein vegetarisches Gericht zur Wahl.\n\nWeitere Fragen? [Schreiben Sie uns.](/contact)","embed":[],"location":"","order":0,"public":true,"slug":"faq-mensa","title":"Mensa und Mittagessen"},{"body":"# Schließfächer\n\n## Wie bekommt mein Kind ein Schließfach?\n\nSchließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).\n\n## Was kostet die Miete?\n\nDie Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.\n\n## Der Schlüssel ist weg — was nun?\n\nWenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","embed":[],"location":"","order":0,"public":true,"slug":"faq-schliessfach","title":"Schließfächer"},{"body":"# Eltern-Euro\n\n## Was ist der Eltern-Euro?\n\nDer Eltern-Euro ist ein freiwilliger Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden — etwa Bastelmaterial oder Ausflüge.\n\n## Ist der Beitrag verpflichtend?\n\nNein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.\n\n## Wohin fließt das Geld?\n\nIn die jeweilige Klassenkasse, verwaltet von den gewählten Klassenelternvertretungen. Fragen dazu über die [Kontaktseite](/contact).","embed":[],"location":"","order":0,"public":true,"slug":"faq-elterneuro","title":"Eltern-Euro"},{"body":"# Elternarbeit und Mitmachen\n\n## Wie kann ich mich engagieren?\n\nOb Schulfest, Basar oder eine helfende Hand bei Ausflügen — über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).\n\n## Muss ich in den Elternbeirat gewählt sein?\n\nNein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.\n\n## Wann finden die Sitzungen statt?\n\nDie öffentlichen Sitzungen und weitere Termine finden Sie unter [Termine](/events).","embed":[],"location":"","order":0,"public":true,"slug":"faq-elternarbeit","title":"Elternarbeit und Mitmachen"},{"body":"# Willkommen beim Elternbeirat der IGMH","embed":["posts","events"],"location":"header","order":1,"public":true,"slug":"home","title":"Startseite"},{"body":"# Beiträge","embed":[],"location":"header","order":4,"public":true,"slug":"posts","title":"Beiträge"},{"body":"# Termine","embed":[],"location":"header","order":5,"public":true,"slug":"events","title":"Termine"}]
diff --git a/pb/pb_migrations/collections_schema.json b/pb/pb_migrations/collections_schema.json
new file mode 100644
index 0000000..0b87140
--- /dev/null
+++ b/pb/pb_migrations/collections_schema.json
@@ -0,0 +1,428 @@
+{
+ "posts": {
+ "id": "pbc_1125843985",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "posts",
+ "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"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "date2862495610",
+ "max": "",
+ "min": "",
+ "name": "date",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "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": "text3685223346",
+ "max": 0,
+ "min": 0,
+ "name": "body",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text2560465762",
+ "max": 0,
+ "min": 0,
+ "name": "slug",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "bool1001664029",
+ "name": "public",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ }
+ ],
+ "indexes": [],
+ "created": "2026-09-23 10:38:12.035Z",
+ "updated": "2026-09-23 11:25:30.698Z",
+ "system": false
+ },
+ "events": {
+ "id": "pbc_1687431684",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "events",
+ "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"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "date2675529103",
+ "max": "",
+ "min": "",
+ "name": "start",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "date16528305",
+ "max": "",
+ "min": "",
+ "name": "end",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "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": "text1587448267",
+ "max": 0,
+ "min": 0,
+ "name": "location",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text3485334036",
+ "max": 0,
+ "min": 0,
+ "name": "note",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "bool1001664029",
+ "name": "public",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ }
+ ],
+ "indexes": [],
+ "created": "2026-09-23 10:38:12.110Z",
+ "updated": "2026-09-23 10:38:12.110Z",
+ "system": false
+ },
+ "pages": {
+ "id": "pbc_3945946014",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "pages",
+ "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": "text3685223346",
+ "max": 0,
+ "min": 0,
+ "name": "body",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "select1587448267",
+ "maxSelect": 1,
+ "name": "location",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": [
+ "header",
+ "footer"
+ ]
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "number4113142680",
+ "max": null,
+ "min": null,
+ "name": "order",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text2560465762",
+ "max": 0,
+ "min": 0,
+ "name": "slug",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "select1203373331",
+ "maxSelect": 3,
+ "name": "embed",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": [
+ "posts",
+ "events",
+ "faqs"
+ ]
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "bool1001664029",
+ "name": "public",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ }
+ ],
+ "indexes": [],
+ "created": "2026-09-23 11:25:35.648Z",
+ "updated": "2026-09-23 11:25:35.648Z",
+ "system": false
+ },
+ "faqs": {
+ "id": "pbc_2923026773",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "faqs",
+ "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": "text3069659470",
+ "max": 0,
+ "min": 0,
+ "name": "question",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "help": "",
+ "hidden": false,
+ "id": "text3671935525",
+ "max": 0,
+ "min": 0,
+ "name": "answer",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "select2638274075",
+ "maxSelect": 1,
+ "name": "topic",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": [
+ "mensa",
+ "schliessfach",
+ "elterneuro",
+ "elternarbeit"
+ ]
+ },
+ {
+ "help": "",
+ "hidden": false,
+ "id": "bool1001664029",
+ "name": "public",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ }
+ ],
+ "indexes": [],
+ "created": "2026-09-23 11:59:13.899Z",
+ "updated": "2026-09-23 11:59:13.899Z",
+ "system": false
+ }
+}