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>
329 lines
10 KiB
TypeScript
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)
|
|
})
|