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
326 lines
12 KiB
JavaScript
326 lines
12 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
|
|
// Die Auslöser und der tägliche Versand.
|
|
//
|
|
// Alle Auslöser tun dasselbe: Sie legen einen Eintrag in `notifications` und
|
|
// sind fertig. Keiner verschickt eine Mail. Das ist der Punkt — sonst löste
|
|
// ein Kommentar, dem drei Antworten folgen, vier Mails aus, und ein Verein,
|
|
// der sich die nicht leisten kann, schaltet nach drei Tagen alles ab.
|
|
//
|
|
// Einmal am Tag nimmt der Cron-Job unten alles Unversendete, macht je Konto
|
|
// eine Mail daraus und hakt es ab.
|
|
//
|
|
// Alle Hooks hängen an `...AfterCreateSuccess` bzw. `...AfterUpdateSuccess`:
|
|
// Erst wenn der Datensatz wirklich steht, gibt es etwas zu melden. Und keiner
|
|
// darf werfen — ein Event anzulegen darf nicht daran scheitern, dass ein
|
|
// Hinweis nicht geschrieben werden konnte. Die Bibliothek fängt deshalb
|
|
// selbst ab; hier steht zusätzlich um jeden Handler ein try.
|
|
|
|
// --- Neues Event im Team ----------------------------------------------------
|
|
|
|
onRecordAfterCreateSuccess((e) => {
|
|
try {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
|
|
const name = e.record.getString('name') || e.record.getString('description') || 'Event'
|
|
const starts = (e.record.getString('starts') || '').slice(0, 10)
|
|
|
|
lib.queueForTeam(e.app, e.record.getString('team'), {
|
|
type: 'event_created',
|
|
actor: e.record.getString('created_by'),
|
|
title: name,
|
|
body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : 'Noch ohne Termin',
|
|
link: `/dashboard/events/${e.record.id}`,
|
|
})
|
|
} catch (err) {
|
|
console.log('[notifications] event_created:', err)
|
|
}
|
|
|
|
e.next()
|
|
}, 'events')
|
|
|
|
// --- Termin oder Ort eines Events verschoben --------------------------------
|
|
//
|
|
// Nur diese beiden Felder. Ein umbenanntes Event oder ein geänderter
|
|
// Beschreibungstext ist keine Nachricht wert; ein verschobener Termin schon —
|
|
// danach richtet sich, wer am Samstag wo steht.
|
|
|
|
onRecordAfterUpdateSuccess((e) => {
|
|
try {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
|
|
const before = e.record.original()
|
|
const movedDate = before.getString('starts') !== e.record.getString('starts')
|
|
const movedPlace = before.getString('location') !== e.record.getString('location')
|
|
|
|
if (!movedDate && !movedPlace) {
|
|
e.next()
|
|
return
|
|
}
|
|
|
|
const name = e.record.getString('name') || e.record.getString('description') || 'Event'
|
|
const starts = (e.record.getString('starts') || '').slice(0, 10)
|
|
const parts = []
|
|
if (movedDate) {
|
|
parts.push(starts ? `neuer Termin ${starts.split('-').reverse().join('.')}` : 'Termin entfernt')
|
|
}
|
|
if (movedPlace) {
|
|
parts.push(`Ort: ${e.record.getString('location') || 'offen'}`)
|
|
}
|
|
|
|
lib.queueForTeam(e.app, e.record.getString('team'), {
|
|
type: 'event_changed',
|
|
title: name,
|
|
body: parts.join(', '),
|
|
link: `/dashboard/events/${e.record.id}`,
|
|
})
|
|
} catch (err) {
|
|
console.log('[notifications] event_changed:', err)
|
|
}
|
|
|
|
e.next()
|
|
}, 'events')
|
|
|
|
// --- Zu einem Event eingetragen ---------------------------------------------
|
|
//
|
|
// Das Gegenstück zu den Teammeldungen: Diese geht an genau einen, und zwar
|
|
// den, um den es geht. Ein Fahrer ohne Konto bekommt nichts — es gibt keine
|
|
// Adresse.
|
|
|
|
onRecordAfterCreateSuccess((e) => {
|
|
try {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
|
|
const userId = lib.userOfRider(e.app, e.record.getString('rider'))
|
|
if (!userId) {
|
|
e.next()
|
|
return
|
|
}
|
|
|
|
const event = e.app.findRecordById('events', e.record.getString('event'))
|
|
const name = event.getString('name') || event.getString('description') || 'Event'
|
|
const starts = (event.getString('starts') || '').slice(0, 10)
|
|
|
|
lib.queueFor(e.app, userId, {
|
|
type: 'event_participation',
|
|
actor: e.record.getString('created_by'),
|
|
team: event.getString('team'),
|
|
title: `Du stehst bei „${name}" auf der Liste`,
|
|
body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : '',
|
|
link: `/dashboard/events/${event.id}`,
|
|
})
|
|
} catch (err) {
|
|
console.log('[notifications] event_participation:', err)
|
|
}
|
|
|
|
e.next()
|
|
}, 'event_participants')
|
|
|
|
// --- Neuer Kommentar an einem Trail -----------------------------------------
|
|
|
|
onRecordAfterCreateSuccess((e) => {
|
|
try {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
|
|
const trail = e.app.findRecordById('trails', e.record.getString('trail'))
|
|
const author = lib.nameOf(e.app, e.record.getString('created_by'))
|
|
const text = e.record.getString('text') || ''
|
|
|
|
lib.queueForTeam(e.app, e.record.getString('team'), {
|
|
type: 'trail_comment',
|
|
actor: e.record.getString('created_by'),
|
|
title: `${author} zu „${trail.getString('name') || 'Trail'}"`,
|
|
// Gekürzt: Die Zusammenfassung ist eine Übersicht und nicht der
|
|
// Ort, an dem man Kommentare liest.
|
|
body: text.length > 140 ? text.slice(0, 140) + ' …' : text,
|
|
link: `/dashboard/trails/${trail.id}`,
|
|
})
|
|
} catch (err) {
|
|
console.log('[notifications] trail_comment:', err)
|
|
}
|
|
|
|
e.next()
|
|
}, 'trail_comments')
|
|
|
|
// --- Neue Meldung an der Strecke --------------------------------------------
|
|
|
|
onRecordAfterCreateSuccess((e) => {
|
|
try {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
|
|
// Erledigte Meldungen entstehen nicht als Meldung — sie werden es.
|
|
if (e.record.getBool('resolved')) {
|
|
e.next()
|
|
return
|
|
}
|
|
|
|
const trail = e.app.findRecordById('trails', e.record.getString('trail'))
|
|
const author = lib.nameOf(e.app, e.record.getString('created_by'))
|
|
|
|
lib.queueForTeam(e.app, e.record.getString('team'), {
|
|
type: 'trail_marker',
|
|
actor: e.record.getString('created_by'),
|
|
title: `${e.record.getString('label') || 'Meldung'} an „${trail.getString('name') || 'Trail'}"`,
|
|
body: `${author}: ${e.record.getString('note') || 'ohne Notiz'}`,
|
|
link: `/dashboard/trails/${trail.id}`,
|
|
})
|
|
} catch (err) {
|
|
console.log('[notifications] trail_marker:', err)
|
|
}
|
|
|
|
e.next()
|
|
}, 'trail_markers')
|
|
|
|
// --- Trail gesperrt oder wieder frei ----------------------------------------
|
|
//
|
|
// Die eine Trail-Meldung, die standardmäßig an ist: Ob eine Strecke gesperrt
|
|
// ist, entscheidet, ob jemand am Sonntag hinfährt.
|
|
|
|
onRecordAfterUpdateSuccess((e) => {
|
|
try {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
|
|
const before = e.record.original()
|
|
const status = e.record.getString('status')
|
|
if (before.getString('status') === status) {
|
|
e.next()
|
|
return
|
|
}
|
|
|
|
lib.queueForTeam(e.app, e.record.getString('team'), {
|
|
type: 'trail_status',
|
|
actor: e.record.getString('status_by'),
|
|
title: `„${e.record.getString('name') || 'Trail'}" ist jetzt: ${status}`,
|
|
body: `Geändert von ${lib.nameOf(e.app, e.record.getString('status_by'))}`,
|
|
link: `/dashboard/trails/${e.record.id}`,
|
|
})
|
|
} catch (err) {
|
|
console.log('[notifications] trail_status:', err)
|
|
}
|
|
|
|
e.next()
|
|
}, 'trails')
|
|
|
|
// --- Neuer Fahrer im Kader --------------------------------------------------
|
|
|
|
onRecordAfterCreateSuccess((e) => {
|
|
try {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
|
|
lib.queueForTeam(e.app, e.record.getString('team'), {
|
|
type: 'team_member',
|
|
title: `${e.record.getString('name') || 'Jemand'} ist neu im Kader`,
|
|
body: '',
|
|
link: '/dashboard/team',
|
|
})
|
|
} catch (err) {
|
|
console.log('[notifications] team_member:', err)
|
|
}
|
|
|
|
e.next()
|
|
}, 'riders')
|
|
|
|
// --- Der tägliche Versand ---------------------------------------------------
|
|
//
|
|
// Einmal am Morgen: alles Unversendete einsammeln, je Konto eine Mail, fertig.
|
|
//
|
|
// Warum 6 Uhr? Weil die Mail vor dem Frühstück da sein soll und nicht
|
|
// mittendrin. Die Zeitzone ist die des Containers (TZ), nicht die des
|
|
// Empfängers — für einen Verein aus einer Gegend ist das dasselbe.
|
|
//
|
|
// Der Job ist absichtlich stumpf: Er fragt nach allem ohne `sent`, gruppiert
|
|
// nach Konto und hakt nur ab, was auch wirklich rausging. Scheitert der
|
|
// Versand an ein Konto, bleiben dessen Einträge stehen und sind morgen wieder
|
|
// dabei — eine verspätete Zusammenfassung ist besser als eine verlorene.
|
|
|
|
cronAdd('notifications_digest', '0 6 * * *', () => {
|
|
const lib = require(`${__hooks}/notifications_lib.js`)
|
|
const mail = require(`${__hooks}/mail_lib.js`)
|
|
|
|
const base = mail.appURL()
|
|
if (!base) {
|
|
console.log('[notifications] APP_URL fehlt — keine Zusammenfassung verschickt.')
|
|
return
|
|
}
|
|
|
|
let pending
|
|
try {
|
|
// `sent = ""` trifft in PocketBase auch NULL — leer und nicht
|
|
// gesetzt sind im Filter dasselbe.
|
|
pending = $app.findRecordsByFilter(lib.QUEUE, 'sent = ""', '+created', 2000, 0)
|
|
} catch (err) {
|
|
console.log('[notifications] Warteschlange nicht lesbar:', err)
|
|
return
|
|
}
|
|
|
|
if (!pending || pending.length === 0) {
|
|
return
|
|
}
|
|
|
|
// Nach Konto bündeln. Ein Konto, eine Mail — darum geht die ganze Übung.
|
|
const byUser = {}
|
|
for (let i = 0; i < pending.length; i++) {
|
|
const uid = pending[i].getString('user')
|
|
if (!byUser[uid]) {
|
|
byUser[uid] = []
|
|
}
|
|
byUser[uid].push(pending[i])
|
|
}
|
|
|
|
for (const uid in byUser) {
|
|
const items = byUser[uid]
|
|
|
|
let user
|
|
try {
|
|
user = $app.findRecordById('users', uid)
|
|
} catch (err) {
|
|
// Konto gelöscht, Einträge noch da. Abhaken, sonst laufen sie
|
|
// jede Nacht aufs Neue in denselben Fehler.
|
|
lib.markSent($app, items)
|
|
continue
|
|
}
|
|
|
|
const address = user.getString('email')
|
|
if (!address) {
|
|
lib.markSent($app, items)
|
|
continue
|
|
}
|
|
|
|
// Nach Art gruppieren, damit die Mail Abschnitte hat und nicht eine
|
|
// Liste aus zwanzig gleich aussehenden Zeilen.
|
|
const groups = []
|
|
const seen = {}
|
|
for (let i = 0; i < items.length; i++) {
|
|
const type = items[i].getString('type')
|
|
if (!seen[type]) {
|
|
seen[type] = { Label: lib.LABELS[type] || type, Items: [] }
|
|
groups.push(seen[type])
|
|
}
|
|
seen[type].Items.push({
|
|
Title: items[i].getString('title'),
|
|
Body: items[i].getString('body'),
|
|
Link: items[i].getString('link') ? base + items[i].getString('link') : '',
|
|
})
|
|
}
|
|
|
|
try {
|
|
mail.sendView(
|
|
$app,
|
|
{ address: address, name: user.getString('name') },
|
|
`${mail.appName()}: ${items.length} Neuigkeit${items.length === 1 ? '' : 'en'}`,
|
|
'digest.html',
|
|
{
|
|
App: mail.appName(),
|
|
Name: user.getString('name') || 'Radlfahrer',
|
|
Count: items.length,
|
|
Groups: groups,
|
|
SettingsLink: `${base}/dashboard/settings/notifications`,
|
|
},
|
|
)
|
|
lib.markSent($app, items)
|
|
} catch (err) {
|
|
// Stehen lassen. Morgen früh noch einmal.
|
|
console.log('[notifications] Versand an', address, 'gescheitert:', err)
|
|
}
|
|
}
|
|
})
|