stammtisch-hersbruck/backend/pb_migrations/1754500000_created_trails.js
Daniel Michelberger f576550429 fix: Präzedenzfehler in trail_flags.deleteRule beheben
&& bindet stärker als || — ohne Klammern galt die
Marker-Referenzprüfung nur für den admins-Zweig. Ein Team-Owner konnte
dadurch einen noch von Markern referenzierten Flag-Typ löschen und
verwaiste Marker hinterlassen.

Klammerung in 1754500000_created_trails.js korrigiert, damit ein
Neuaufbau der Datenbank die richtige Regel von Anfang an bekommt.
1754500200_trail_flags_delete_rule.js (bereits mit der fehlerhaften
Regel angewendet) inhaltlich ebenfalls korrigiert, und eine neue
Migration 1754500300_trail_flags_delete_rule_precedence.js trägt die
Korrektur für bestehende Instanzen nach. Snapshot aktualisiert.
2026-08-06 17:13:35 +02:00

271 lines
9.2 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// Trails: GPX-Tracks mit Versionen, verorteten Markern und Kommentaren.
//
// Reihenfolge ist wichtig: trails.current zeigt auf trail_versions,
// trail_versions.trail zeigt zurück auf trails. Deshalb wird trails zuerst
// OHNE current angelegt, dann trail_versions, und current danach ergänzt.
migrate((app) => {
const teams = app.findCollectionByNameOrId('teams')
const users = app.findCollectionByNameOrId('users')
// Für jede Collection gleich: Standardfelder, die PocketBase nicht
// selbst anlegt.
const timestamps = [
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
]
const teamScoped = (name) => ({
name,
type: 'relation',
required: true,
maxSelect: 1,
collectionId: teams.id,
cascadeDelete: true,
})
// --- trails (noch ohne current) --------------------------------------
const trails = new Collection({
type: 'base',
name: 'trails',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
// Bearbeiten dürfen Paten und Team-Admins
updateRule:
'stewards.id ?= @request.auth.id' +
' || team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id',
// Löschen nur Admins — am Trail hängen fremde Marker und Kommentare
deleteRule:
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id',
fields: [
teamScoped('team'),
{ name: 'name', type: 'text', required: true, max: 200 },
{ name: 'description', type: 'editor' },
{
name: 'status',
type: 'select',
maxSelect: 1,
values: ['offen', 'eingeschraenkt', 'gesperrt'],
},
{
name: 'stewards',
type: 'relation',
maxSelect: 0,
collectionId: users.id,
cascadeDelete: false,
},
{
name: 'event',
type: 'relation',
maxSelect: 1,
collectionId: app.findCollectionByNameOrId('events').id,
cascadeDelete: false,
},
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(trails)
// --- trail_versions ---------------------------------------------------
const versions = new Collection({
type: 'base',
name: 'trail_versions',
listRule: 'trail.team.users.id ?= @request.auth.id',
viewRule: 'trail.team.users.id ?= @request.auth.id',
createRule:
'trail.stewards.id ?= @request.auth.id' +
' || trail.team.owner.id ?= @request.auth.id' +
' || trail.team.admins.id ?= @request.auth.id',
// Versionen sind unveränderlich — eine Korrektur ist eine neue Version
updateRule: null,
deleteRule:
'trail.team.owner.id ?= @request.auth.id' +
' || trail.team.admins.id ?= @request.auth.id',
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
{
name: 'gpx',
type: 'file',
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ['application/gpx+xml', 'application/xml', 'text/xml'],
},
{ name: 'geojson', type: 'json', maxSize: 5000000 },
{ name: 'elevation', type: 'json', maxSize: 2000000 },
{ name: 'bounds', type: 'json', maxSize: 1000 },
{ name: 'distance_m', type: 'number' },
{ name: 'ascent_m', type: 'number' },
{ name: 'note', type: 'text', max: 500 },
{
name: 'uploaded_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(versions)
// --- trails.current nachtragen ---------------------------------------
trails.fields.add(new Field({
name: 'current',
type: 'relation',
maxSelect: 1,
collectionId: versions.id,
cascadeDelete: false,
}))
app.save(trails)
// --- trail_flags ------------------------------------------------------
const adminOnly =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
const flags = new Collection({
type: 'base',
name: 'trail_flags',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: adminOnly,
updateRule: adminOnly,
// Löschen nur, solange kein Marker diesen Typ verwendet — sonst
// bliebe ein Pflichtfeld zurück, das ins Leere zeigt.
// Klammern nötig: && bindet stärker als || — ohne sie gälte die
// Marker-Prüfung nur für den admins-Zweig, nicht für owner.
deleteRule: '(' + adminOnly + ') && @collection.trail_markers.flag ?!= id',
fields: [
teamScoped('team'),
{ name: 'label', type: 'text', required: true, max: 60 },
{ name: 'icon', type: 'text', max: 40 },
{ name: 'color', type: 'text', max: 9 },
{
name: 'severity',
type: 'select',
maxSelect: 1,
values: ['info', 'warnung', 'kritisch'],
},
...timestamps,
],
})
app.save(flags)
// --- trail_markers ----------------------------------------------------
// Anlegen darf jedes Team-Mitglied; ändern nur der Ersteller, Paten
// oder Admins.
const ownerOrSteward =
'created_by.id ?= @request.auth.id' +
' || trail.stewards.id ?= @request.auth.id' +
' || team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
const markers = new Collection({
type: 'base',
name: 'trail_markers',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: ownerOrSteward,
deleteRule: ownerOrSteward,
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
teamScoped('team'),
{
name: 'flag',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: flags.id,
cascadeDelete: false,
},
{ name: 'lat', type: 'number', required: true },
{ name: 'lng', type: 'number', required: true },
{ name: 'note', type: 'text', max: 500 },
{ name: 'resolved', type: 'bool' },
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(markers)
// --- trail_comments ---------------------------------------------------
const comments = new Collection({
type: 'base',
name: 'trail_comments',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: ownerOrSteward,
deleteRule: ownerOrSteward,
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
teamScoped('team'),
{ name: 'text', type: 'text', required: true, max: 2000 },
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(comments)
}, (app) => {
// Rückwärts in umgekehrter Reihenfolge, damit keine Relation ins Leere
// zeigt. trails zuletzt, weil alle anderen darauf verweisen.
for (const name of [
'trail_comments',
'trail_markers',
'trail_flags',
'trail_versions',
'trails',
]) {
try {
app.delete(app.findCollectionByNameOrId(name))
} catch {
// Bereits entfernt — nichts zu tun.
}
}
})