stammtisch-hersbruck/backend/pb_hooks/team_invites.pb.js
Daniel Michelberger 279796111c wip: Der abgebrochene Kurbeler-Umbau, wie er hier liegengeblieben ist
Kein fertiger Stand, sondern ein Zwischenstand: Hier wurde im September 2026
angefangen, die App auf „Kurbeler" umzubenennen — Wortmarke, Icons, brand.ts,
Benachrichtigungen, das Benutzerverzeichnis. Weitergegangen ist es dann im
eigenen Repo `~/Dokumente/Projekte/kurbeler`, das die vollständige Historie
dieses Repos mitgenommen hat.

Committet wird das hier nicht, weil es gebraucht würde, sondern damit es nicht
als Haufen unversionierter Dateien im Arbeitsverzeichnis verrottet. Wer in
zwei Jahren nachsieht, findet so eine Geschichte statt eines Rätsels — und das
Stammtisch-Wappen, das nur hier je existiert hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTw6xVYgjMy9AQtfs1Gdyu
2026-09-11 15:42:33 +02:00

328 lines
12 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// Die öffentliche Seite der Einladungslinks.
//
// Warum überhaupt eigene Routen? Wer einem Team über einen Link beitritt, ist
// noch nicht eingeloggt und muss dabei drei Dinge tun, die keine
// Collection-Regel erlauben darf:
//
// 1. ein Konto anlegen — `users.createRule` verlangt einen Login, sonst
// wäre es eine offene Registrierung für jedermann;
// 2. sich in `teams.users` eintragen — das darf sonst nur die Teamleitung;
// 3. einen Fahrer im Kader anlegen — dito.
//
// Ein Hook läuft an den Regeln vorbei und kann deshalb genau diese drei
// Schritte erlauben, aber nur zusammen und nur gegen einen gültigen Token.
// Die Collection `team_invites` selbst bleibt für Nicht-Mitglieder unsichtbar.
//
// Routen:
// GET /api/team-invites/{token} — öffentlich, zeigt das Team
// POST /api/team-invites/{token}/accept — öffentlich, Konto + Beitritt
// POST /api/team-invites/{token}/join — eingeloggt, nur Beitritt
//
// Jeder Handler holt sich seine Helfer selbst per require(): PocketBase führt
// Route-Handler in einer eigenen JS-Laufzeit aus, der Dateikopf hier ist dort
// nicht sichtbar.
// --- Wofür gilt der Link? ---------------------------------------------------
//
// Öffentlich, weil die Seite den Teamnamen zeigen soll, bevor jemand seine
// Daten einträgt. Mehr als Name und Logo gibt sie nicht preis; die Einladung
// selbst — Zähler, Ablauf, wer sie erzeugt hat — bleibt drin.
routerAdd('GET', '/api/team-invites/{token}', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const invite = lib.findValidInvite(e.app, e.request.pathValue('token'))
if (!invite) {
throw new NotFoundError('Diese Einladung gibt es nicht mehr.')
}
return e.json(200, {
team: lib.teamPayload(e.app, invite),
recipient: lib.recipientPayload(e.app, invite),
})
})
// --- Konto anlegen und beitreten -------------------------------------------
routerAdd('POST', '/api/team-invites/{token}/accept', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const data = new DynamicModel({
email: '',
password: '',
passwordConfirm: '',
name: '',
})
e.bindBody(data)
const email = (data.email || '').trim()
const name = (data.name || '').trim()
if (!name) {
throw new BadRequestError('Bitte einen Namen angeben.')
}
if (email.indexOf('@') < 1) {
throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.')
}
if ((data.password || '').length < 8) {
throw new BadRequestError('Das Passwort braucht mindestens 8 Zeichen.')
}
if (data.password !== data.passwordConfirm) {
throw new BadRequestError('Die beiden Passwörter stimmen nicht überein.')
}
let result
e.app.runInTransaction((txApp) => {
// Innerhalb der Transaktion noch einmal nachsehen: Zwischen Anzeige
// der Seite und dem Absenden kann die Einladung abgelaufen,
// aufgebraucht oder gelöscht worden sein.
const invite = lib.findValidInvite(txApp, e.request.pathValue('token'))
if (!invite) {
throw new BadRequestError('Diese Einladung gilt nicht mehr.')
}
// Eine persönliche Einladung ging an genau eine Adresse. Sie hier
// erneut zu prüfen ist der Punkt, an dem es zählt: Das Feld auf der
// Seite ist gesperrt, aber ein gesperrtes Feld ist eine Bitte und
// keine Bedingung — die Anfrage kann jeder von Hand stellen.
const invitedEmail = invite.getString('email')
if (invitedEmail && invitedEmail.toLowerCase() !== email.toLowerCase()) {
throw new BadRequestError(
'Diese Einladung gilt für eine andere E-Mail-Adresse.',
)
}
// Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique".
// Der Hinweis auf den zweiten Weg ist hier mehr wert als die
// Fehlermeldung.
let existing = null
try {
existing = txApp.findAuthRecordByEmail('users', email)
} catch (err) {
existing = null
}
if (existing) {
throw new BadRequestError(
'Zu dieser E-Mail-Adresse gibt es schon ein Konto. ' +
'Melde dich damit an und öffne den Link danach erneut.',
)
}
const user = new Record(txApp.findCollectionByNameOrId('users'))
user.setEmail(email)
user.setPassword(data.password)
user.setVerified(false)
user.set('emailVisibility', false)
user.set('name', name)
txApp.save(user)
result = lib.joinTeam(txApp, invite, user, name)
})
// Kein Auth-Token in der Antwort: Die App meldet sich anschließend ganz
// normal mit E-Mail und Passwort an und läuft damit durch dieselbe
// Anmeldung wie jeder andere auch.
return e.json(200, { ok: true, team: result.teamId })
})
// --- Als bereits angemeldeter Nutzer beitreten ------------------------------
routerAdd('POST', '/api/team-invites/{token}/join', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const data = new DynamicModel({ name: '' })
e.bindBody(data)
const user = e.auth
if (!user) {
throw new UnauthorizedError()
}
let result
e.app.runInTransaction((txApp) => {
const invite = lib.findValidInvite(txApp, e.request.pathValue('token'))
if (!invite) {
throw new BadRequestError('Diese Einladung gilt nicht mehr.')
}
// Fällt der Name weg, stünde der Beitretende ohne Namen im Kader. Der
// Kontoname ist die bessere Näherung als gar nichts.
const name = (data.name || '').trim() || user.getString('name')
result = lib.joinTeam(txApp, invite, user, name)
})
return e.json(200, { ok: true, team: result.teamId })
}, $apis.requireAuth())
// --- Persönliche Einladung: Konto per Mail statt Passwort per Zuruf --------
//
// Der Ersatz für „Konto anlegen" im Kader. Dort tippte die Teamleitung eine
// Adresse und ein Passwort ein und musste dieses Passwort anschließend
// irgendwie zum Fahrer bringen — über einen Kanal, den niemand geplant hat.
// Sie kannte es außerdem, und der Fahrer hatte eins, das er sich nicht
// ausgesucht hat.
//
// Jetzt geht eine Mail raus, und das Passwort entsteht erst beim Empfänger.
//
// Warum eine Route und kein Collection-Create mit anschließendem Versand?
// Weil beides zusammengehört: Eine Einladung, die niemanden erreicht, ist
// keine — sie stünde nur als Datensatz herum und die Teamleitung wartete auf
// jemanden, der nie eine Mail bekommen hat. Hier scheitert beides zusammen
// oder gar nichts.
//
// POST /api/team-invites/send { rider, email, days? }
routerAdd('POST', '/api/team-invites/send', (e) => {
const lib = require(`${__hooks}/team_invites_lib.js`)
const mail = require(`${__hooks}/mail_lib.js`)
const user = e.auth
if (!user) {
throw new UnauthorizedError()
}
const base = mail.appURL()
if (!base) {
// Ohne Adresse des Frontends gibt es keinen Link, und ohne Link ist
// die Mail wertlos. Das hier ist ein Konfigurationsfehler und gehört
// sichtbar gemeldet, nicht stillschweigend umschifft.
throw new InternalServerError(
'APP_URL ist nicht gesetzt — ohne sie lässt sich kein Einladungslink bilden.',
)
}
const data = new DynamicModel({ rider: '', email: '', days: 14 })
e.bindBody(data)
const email = (data.email || '').trim()
if (email.indexOf('@') < 1) {
throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.')
}
let rider
try {
rider = e.app.findRecordById('riders', (data.rider || '').trim())
} catch (err) {
throw new NotFoundError('Diesen Fahrer gibt es nicht.')
}
if (rider.getString('user')) {
throw new BadRequestError('Dieser Fahrer hat schon ein Konto.')
}
const team = e.app.findRecordById('teams', rider.getString('team'))
// Einladen darf die Teamleitung — dieselbe Bedingung wie in der
// createRule von team_invites, hier von Hand geprüft, weil die Route an
// den Collection-Regeln vorbeiläuft.
let allowed = false
try {
e.app.findFirstRecordByFilter(
'teams',
'id = {:team} && (owner.id ?= {:user} || admins.id ?= {:user})',
{ team: team.id, user: user.id },
)
allowed = true
} catch (err) {
allowed = false
}
if (!allowed) {
throw new ForbiddenError('Einladen darf nur die Teamleitung.')
}
// Ohne die Vorabprüfung liefe der Eingeladene erst beim Anlegen des
// Kontos in „Value must be unique" — nach der Mail, nach dem Klick, am
// Ende eines Weges, den er umsonst gegangen wäre.
let existing = null
try {
existing = e.app.findAuthRecordByEmail('users', email)
} catch (err) {
existing = null
}
if (existing) {
throw new BadRequestError(
'Zu dieser Adresse gibt es schon ein Konto. Es kann über einen ' +
'Einladungslink ins Team kommen.',
)
}
const days = parseInt(data.days, 10)
let expires = ''
if (days > 0) {
const until = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
expires = until.toISOString().replace('T', ' ').replace('Z', 'Z')
}
let invite
e.app.runInTransaction((txApp) => {
// Eine zweite Einladung an denselben Fahrer ersetzt die erste. Sonst
// stapelten sich bei jedem „nochmal senden" Datensätze, von denen
// jeder für sich gültig bliebe — und ein zurückgezogener wäre keiner.
try {
const old = txApp.findFirstRecordByFilter(
'team_invites',
'rider = {:rider}',
{ rider: rider.id },
)
txApp.delete(old)
} catch (err) {
// Keine vorhandene — der Normalfall.
}
invite = new Record(txApp.findCollectionByNameOrId('team_invites'))
invite.set('team', team.id)
invite.set('rider', rider.id)
invite.set('email', email)
invite.set('created_by', user.id)
invite.set('max_uses', 1)
invite.set('uses', 0)
if (expires) {
invite.set('expires', expires)
}
txApp.save(invite)
})
const name = rider.getString('name') || 'Radlfahrer'
const teamName = team.getString('name') || mail.appName()
// Der Versand steht bewusst NACH der Transaktion: Ein SMTP-Aufruf gehört
// nicht in eine offene Datenbanktransaktion, er dauert und kann hängen.
// Scheitert er, verschwindet die Einladung wieder — eine, von der niemand
// weiß, wäre schlimmer als keine.
try {
mail.sendView(
e.app,
{ address: email, name: name },
`${teamName} lädt dich zu ${mail.appName()} ein`,
'invite.html',
{
App: mail.appName(),
Team: teamName,
Name: name,
Link: `${base}/invite/${invite.getString('token')}`,
Expires: mail.formatDay(expires),
},
)
} catch (err) {
try {
e.app.delete(invite)
} catch (cleanupErr) {
// Aufräumen ist der Versuch wert, aber nicht der Fehler, den der
// Aufrufer sehen soll.
}
throw new InternalServerError(
'Die Einladung konnte nicht verschickt werden. Stimmen die SMTP-Einstellungen?',
)
}
invite.set('sent', new Date().toISOString().replace('T', ' '))
e.app.save(invite)
return e.json(200, { ok: true, email: email, expires: expires })
}, $apis.requireAuth())