/// // 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) } } })