stammtisch-hersbruck/frontend/scripts/migrate-teams.ts
Daniel Michelberger 1061a8b9ad chore: Repo-Struktur mit frontend/ und backend/ aufsetzen
Die SvelteKit-App liegt unter frontend/, das Backend unter backend/ als
eigenständige PocketBase-Instanz mit Dockerfile, docker-compose und
versioniertem Schema.

- PocketBase-URL über PUBLIC_PB_URL konfigurierbar, Default bleibt die
  produktive Instanz https://api.stammtisch-hersbruck.de
- Schema als Snapshot-Migration der sechs fachlichen Collections
  (users, teams, events, runs, riders, times), abgezogen von der
  produktiven Instanz. Verifiziert: ein Erststart gegen leere pb_data
  legt alle sechs an, Felder und API-Rules stimmen überein.
- pb_data und pb_migrations als Bind-Mounts, damit Daten persistieren und
  im Admin-UI erzeugte Migrationen im Repo landen
- Veraltete Dokumentation entfernt: pocketbase_schema.json nannte
  Collections (stages, results, organizers), die es nicht gibt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:16:43 +02:00

329 lines
10 KiB
TypeScript

/**
* Schema-Migration: Teams einführen
*
* - neue Collection 'teams' (name, owner, admins[], users[])
* - events, riders, runs, times bekommen ein 'team'-Feld
* - listRule/viewRule auf allen vier wird auf team.users.id ?= @request.auth.id umgestellt
* - events.users wird entfernt
* - Migration der Daten:
* Team "Stammtisch Hersbruck" anlegen
* Owner = aktuell eingeloggter User (über .env: PB_OWNER_EMAIL falls gesetzt, sonst erster events.users)
* users = Union aller events.users
* events/riders/runs/times werden alle diesem Team zugeordnet
*
* Aufruf: npm run migrate-teams
*/
import { readFileSync } from 'node:fs'
// .env parsen — Zeilen die mit . anfangen werden an die vorherige Variable angehängt
// (Workaround für zerbrochene JWT-Tokens über mehrere Zeilen)
const env = readFileSync('.env', 'utf8').split('\n')
let lastKey = ''
for (const line of env) {
const m = line.match(/^([A-Z_]+)=(.*)$/)
if (m) {
process.env[m[1]] = m[2]
lastKey = m[1]
} else if (lastKey && line.length > 0) {
process.env[lastKey] = (process.env[lastKey] ?? '') + line
}
}
const API = process.env.PB_TYPEGEN_URL?.replace(/\/$/, '') ?? 'https://api.stammtisch-hersbruck.de'
const TOKEN = process.env.PB_SUPERUSER_TOKEN
if (!TOKEN) {
console.error('PB_SUPERUSER_TOKEN fehlt')
process.exit(1)
}
const headers = { Authorization: TOKEN, 'Content-Type': 'application/json' }
async function req(method: string, path: string, body?: unknown) {
const res = await fetch(`${API}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(`${method} ${path}${res.status}: ${await res.text()}`)
return res.status === 204 ? null : await res.json()
}
async function getCollection(name: string) {
try {
return await req('GET', `/api/collections/${name}`)
} catch {
return null
}
}
async function createCollection(body: any) {
return req('POST', '/api/collections', body)
}
async function updateCollection(name: string, body: any) {
return req('PATCH', `/api/collections/${name}`, body)
}
async function listAll(collection: string) {
const all: any[] = []
let page = 1
while (true) {
const res: any = await req('GET', `/api/collections/${collection}/records?page=${page}&perPage=200`)
all.push(...res.items)
if (page >= res.totalPages || res.totalPages === 0) break
page++
}
return all
}
async function updateRecord(collection: string, id: string, data: any) {
return req('PATCH', `/api/collections/${collection}/records/${id}`, data)
}
async function createRecord(collection: string, data: any) {
return req('POST', `/api/collections/${collection}/records`, data)
}
function hasField(coll: any, name: string) {
return coll.fields.some((f: any) => f.name === name)
}
const USERS_AUTH = '_pb_users_auth_'
// ---- Schritt 1: teams Collection anlegen ----
async function ensureTeamsCollection() {
console.log('\n== teams Collection ==')
const existing = await getCollection('teams')
if (existing) {
console.log(' = teams existiert bereits')
return existing
}
const body = {
name: 'teams',
type: 'base',
listRule: 'users.id ?= @request.auth.id',
viewRule: 'users.id ?= @request.auth.id',
createRule: '@request.auth.id != ""',
updateRule: 'owner.id ?= @request.auth.id || admins.id ?= @request.auth.id',
deleteRule: 'owner.id ?= @request.auth.id',
fields: [
{
name: 'name',
type: 'text',
required: true,
presentable: true,
max: 100,
min: 1,
pattern: '',
autogeneratePattern: '',
},
{
name: 'owner',
type: 'relation',
required: true,
maxSelect: 1,
minSelect: 1,
collectionId: USERS_AUTH,
cascadeDelete: false,
},
{
name: 'admins',
type: 'relation',
required: false,
maxSelect: 999,
minSelect: 0,
collectionId: USERS_AUTH,
cascadeDelete: false,
},
{
name: 'users',
type: 'relation',
required: false,
maxSelect: 999,
minSelect: 0,
collectionId: USERS_AUTH,
cascadeDelete: false,
},
{
name: 'created',
type: 'autodate',
onCreate: true,
onUpdate: false,
},
{
name: 'updated',
type: 'autodate',
onCreate: true,
onUpdate: true,
},
],
}
const created = await createCollection(body)
console.log(' + Collection "teams" angelegt')
return created
}
// ---- Schritt 2: team-Feld zu events/riders/runs/times hinzufügen ----
async function addTeamField(collName: string) {
const coll = await getCollection(collName)
if (!coll) throw new Error(`Collection ${collName} fehlt`)
if (hasField(coll, 'team')) {
console.log(` = ${collName}.team existiert bereits`)
return
}
const fields = [
...coll.fields,
{
name: 'team',
type: 'relation',
required: false,
maxSelect: 1,
minSelect: 0,
collectionId: (await getCollection('teams'))!.id,
cascadeDelete: false,
},
]
await updateCollection(collName, { fields })
console.log(` + ${collName}.team hinzugefügt`)
}
// ---- Schritt 3: Default-Team anlegen + alle Records zuordnen ----
async function migrateData(teamsId: string) {
console.log('\n== Daten-Migration ==')
// Owner-User suchen
const ownerEmail = process.env.PB_OWNER_EMAIL || 'stammtisch-hersbruck@dne.name'
const usersRes: any = await req(
'GET',
`/api/collections/${USERS_AUTH}/records?filter=` + encodeURIComponent(`email = "${ownerEmail}"`),
)
if (!usersRes.items.length) {
throw new Error(`User mit E-Mail "${ownerEmail}" nicht gefunden. Setze PB_OWNER_EMAIL in .env.`)
}
const owner = usersRes.items[0]
console.log(` Owner: ${owner.email} (${owner.id})`)
// Existierendes Team suchen
const teamsList: any = await req('GET', `/api/collections/teams/records?filter=` + encodeURIComponent(`name = "Stammtisch Hersbruck"`))
let team: any = teamsList.items[0]
if (!team) {
// alle User aus events.users sammeln
const events = await listAll('events')
const userSet = new Set<string>([owner.id])
for (const e of events) {
if (Array.isArray(e.users)) for (const u of e.users) userSet.add(u)
}
team = await createRecord('teams', {
name: 'Stammtisch Hersbruck',
owner: owner.id,
admins: [],
users: [...userSet],
})
console.log(` + Team "Stammtisch Hersbruck" mit ${userSet.size} Mitgliedern angelegt`)
} else {
console.log(` = Team "Stammtisch Hersbruck" existiert (${team.id})`)
}
// Datensätze ohne team patchen
for (const collName of ['events', 'riders', 'runs', 'times']) {
const records = await listAll(collName)
let touched = 0
for (const r of records) {
if (!r.team) {
await updateRecord(collName, r.id, { team: team.id })
touched++
}
}
console.log(`${collName}: ${touched} Records zugeordnet (${records.length - touched} bereits gesetzt)`)
}
return team
}
// ---- Schritt 4: Rules auf team-basiert umstellen ----
async function updateRules() {
console.log('\n== Rules auf team-basiert umstellen ==')
const rules = {
events: {
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: 'team.users.id ?= @request.auth.id',
deleteRule: 'team.users.id ?= @request.auth.id',
},
riders: {
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: 'team.users.id ?= @request.auth.id',
deleteRule: 'team.users.id ?= @request.auth.id',
},
runs: {
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: 'team.users.id ?= @request.auth.id',
deleteRule: 'team.users.id ?= @request.auth.id',
},
times: {
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: 'team.users.id ?= @request.auth.id',
deleteRule: 'team.users.id ?= @request.auth.id',
},
}
for (const [coll, r] of Object.entries(rules)) {
await updateCollection(coll, r)
console.log(` ~ ${coll} Rules aktualisiert`)
}
}
// ---- Schritt 5: events.users entfernen ----
async function removeEventsUsers() {
console.log('\n== events.users entfernen ==')
const coll = await getCollection('events')
if (!coll) return
if (!hasField(coll, 'users')) {
console.log(' = events.users existiert nicht (mehr)')
return
}
const fields = coll.fields.filter((f: any) => f.name !== 'users')
await updateCollection('events', { fields })
console.log(' - events.users entfernt')
}
// ---- Main ----
async function main() {
console.log(`Migration gegen ${API}`)
const teams = await ensureTeamsCollection()
console.log('\n== team-Feld zu Collections hinzufügen ==')
await addTeamField('events')
await addTeamField('riders')
await addTeamField('runs')
await addTeamField('times')
await migrateData(teams.id)
await updateRules()
await removeEventsUsers()
console.log('\n✓ Teams-Migration fertig')
}
main().catch((e) => {
console.error('\n✗ Fehler:', e.message)
process.exit(1)
})