From 9a6e3629bb49e06e3ecfd7dde91ee908187db05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Fri, 2 Oct 2026 22:47:11 +0200 Subject: [PATCH 1/3] fix: migrate notifications to go --- main.go | 3 +- pb_hooks/notifications.pb.js | 69 -------- pb_hooks/recurring.js | 18 --- pb_hooks/recurringExpenses.pb.js | 6 +- .../1790974011_deleted_notifications.js | 117 ++++++++++++++ webpush.go | 149 +++++++++++++++--- 6 files changed, 251 insertions(+), 111 deletions(-) delete mode 100644 pb_hooks/notifications.pb.js create mode 100644 pb_migrations/1790974011_deleted_notifications.js diff --git a/main.go b/main.go index 3f09325..af276d6 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ // Custom PocketBase build. Behaves like the stock binary (JS hooks in // pb_hooks, JS migrations in pb_migrations, static frontend from pb_public) -// with one addition: Web Push delivery for the `notifications` collection, -// which the stock binary can't do because the JSVM lacks the required crypto. +// with native Web Push delivery and event hooks for notifications. // See webpush.go. package main diff --git a/pb_hooks/notifications.pb.js b/pb_hooks/notifications.pb.js deleted file mode 100644 index c1e98bd..0000000 --- a/pb_hooks/notifications.pb.js +++ /dev/null @@ -1,69 +0,0 @@ -/// - -// Fans out records into the `notifications` collection when expenses and -// settlements are created via the API. The Go side (main.go/webpush.go) picks -// up every created notification and delivers it as a Web Push message to all -// of the recipient's registered devices (`push_subscriptions`). -// -// Notification creation runs after e.next(), so the original request has -// already succeeded — failures here are logged and never fail the request. - -onRecordCreateRequest((e) => { - e.next(); - - try { - const actor = e.auth; - const group = e.app.findRecordById("groups", e.record.get("group")); - const actorName = actor?.get("name") || actor?.get("email") || "Someone"; - const amount = e.record.get("amount"); - const currency = (e.record.get("currency") || "").toUpperCase(); - - const collection = e.app.findCollectionByNameOrId("notifications"); - const members = group.get("members") || []; - for (const memberId of members) { - if (actor && memberId === actor.id) continue; - const notification = new Record(collection); - notification.set("user", memberId); - notification.set("title", group.get("name")); - notification.set("body", `${actorName} added "${e.record.get("title")}" — ${amount} ${currency}`); - notification.set("url", `/group/${group.id}`); - e.app.save(notification); - } - } catch (err) { - e.app.logger().error("Failed to create expense notifications", "error", String(err)); - } -}, "expenses"); - -onRecordCreateRequest((e) => { - e.next(); - - try { - const actor = e.auth; - const group = e.app.findRecordById("groups", e.record.get("group")); - const amount = e.record.get("amount"); - const currency = (e.record.get("currency") || "").toUpperCase(); - - let fromName = "Someone"; - try { - const from = e.app.findRecordById("users", e.record.get("from")); - fromName = from.get("name") || from.get("email") || fromName; - } catch { - // keep fallback - } - - const collection = e.app.findCollectionByNameOrId("notifications"); - const recipients = [e.record.get("from"), e.record.get("to")].filter( - (id) => id && (!actor || id !== actor.id), - ); - for (const userId of recipients) { - const notification = new Record(collection); - notification.set("user", userId); - notification.set("title", group.get("name")); - notification.set("body", `${fromName} paid ${amount} ${currency}`); - notification.set("url", `/group/${group.id}`); - e.app.save(notification); - } - } catch (err) { - e.app.logger().error("Failed to create settlement notifications", "error", String(err)); - } -}, "settlements"); diff --git a/pb_hooks/recurring.js b/pb_hooks/recurring.js index 2993aa6..9e8bc9e 100644 --- a/pb_hooks/recurring.js +++ b/pb_hooks/recurring.js @@ -147,23 +147,6 @@ function createExpense(app, template, group, splits, dueDate, baseAmount) { return expense; } -// Nobody "acted" on an automatic expense, so every member gets notified, -// including the payer. The notifications.pb.js hook only fires for API -// requests, so this is done here for records created by the cron. -function notifyMembers(app, group, expense) { - const collection = app.findCollectionByNameOrId("notifications"); - const amount = expense.get("amount"); - const currency = (expense.get("currency") || "").toUpperCase(); - for (const memberId of group.get("members") || []) { - const notification = new Record(collection); - notification.set("user", memberId); - notification.set("title", group.get("name")); - notification.set("body", `Recurring expense "${expense.get("title")}" — ${amount} ${currency}`); - notification.set("url", `/group/${group.id}`); - app.save(notification); - } -} - /** * Create every occurrence of one template that is due at `now`, advancing * `nextDate` past them and deactivating the template once `endDate` is @@ -203,7 +186,6 @@ function materializeTemplate(app, template, now) { throw new Error(`no exchange rate for ${currency}->${base} on ${dayOf(nextDate)}`); } const expense = createExpense(tx, template, group, splits, nextDate, baseAmount); - notifyMembers(tx, group, expense); created++; const following = nextOccurrence(nextDate, anchor, frequency, interval); diff --git a/pb_hooks/recurringExpenses.pb.js b/pb_hooks/recurringExpenses.pb.js index 596c764..5936a5d 100644 --- a/pb_hooks/recurringExpenses.pb.js +++ b/pb_hooks/recurringExpenses.pb.js @@ -1,8 +1,8 @@ /// -// Turns due `recurring_expenses` templates into regular expenses (plus splits -// and notifications). The logic lives in ./recurring.js — see there for the -// data model — and is require()'d inside each handler because handlers run in +// Turns due `recurring_expenses` templates into regular expenses (plus splits). +// Push notifications are delivered by Go hooks on `expenses`. The logic lives +// in ./recurring.js — see there for the data model — and is require()'d inside // isolated contexts that cannot see top-level functions of this file. // // Runs hourly: templates are compared against "now", so the exact hour does diff --git a/pb_migrations/1790974011_deleted_notifications.js b/pb_migrations/1790974011_deleted_notifications.js new file mode 100644 index 0000000..dc43576 --- /dev/null +++ b/pb_migrations/1790974011_deleted_notifications.js @@ -0,0 +1,117 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1685465007"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3182418120", + "max": 0, + "min": 0, + "name": "body", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_1685465007", + "indexes": [], + "listRule": null, + "name": "notifications", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/webpush.go b/webpush.go index c709731..dc4da54 100644 --- a/webpush.go +++ b/webpush.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "sync" "syscall" @@ -107,9 +108,10 @@ func vapidSubject() string { return "temporary@example.com" } -// registerWebPush delivers every created `notifications` record to all the -// recipient's registered devices. Records are created by pb_hooks (see -// pb_hooks/notifications.pb.js) — this is only the transport. +// registerWebPush sets up Web Push delivery for the app. +// Direct hooks on `expenses` (both user-created and recurring) and +// `settlements` deliver push notifications to group members without +// storing intermediate records in the db. func registerWebPush(app *pocketbase.PocketBase) { var keys *vapidKeys @@ -127,8 +129,6 @@ func registerWebPush(app *pocketbase.PocketBase) { // Diagnostic: push a test message to every device of the caller, // synchronously, and report what the push service said for each one. - // Bypasses the `notifications` collection so the result can be - // returned to the caller instead of fire-and-forget. se.Router.POST("/api/push/test", func(e *core.RequestEvent) error { subscriptions, err := findPushSubscriptions(e.App, e.Auth.Id) if err != nil { @@ -161,36 +161,147 @@ func registerWebPush(app *pocketbase.PocketBase) { return se.Next() }) - app.OnRecordAfterCreateSuccess("notifications").BindFunc(func(e *core.RecordEvent) error { - if keys == nil { - return e.Next() + // Fans out notifications to group members when expenses are created via the API + app.OnRecordCreateRequest("expenses").BindFunc(func(e *core.RecordRequestEvent) error { + if err := e.Next(); err != nil { + return err + } + + actor := e.Auth + actorName := "Someone" + if actor != nil { + if name := actor.GetString("name"); name != "" { + actorName = name + } else if email := actor.GetString("email"); email != "" { + actorName = email + } + } + + group, err := e.App.FindRecordById("groups", e.Record.GetString("group")) + if err != nil { + e.App.Logger().Error("webpush: failed to load group for expense notification", + "error", err, "group", e.Record.GetString("group")) + return nil + } + + amount := strconv.FormatFloat(e.Record.GetFloat("amount"), 'f', -1, 64) + currency := strings.ToUpper(e.Record.GetString("currency")) + title := group.GetString("name") + body := fmt.Sprintf(`%s added "%s" — %s %s`, actorName, e.Record.GetString("title"), amount, currency) + targetURL := fmt.Sprintf("/group/%s", group.Id) + + for _, memberId := range group.GetStringSlice("members") { + if actor != nil && memberId == actor.Id { + continue + } + sendNotificationToUser(e.App, keys, memberId, title, body, targetURL) } - subscriptions, err := findPushSubscriptions(e.App, e.Record.GetString("user")) + return nil + }) + + // Fans out notifications to payer/recipient when settlements are created via the API + app.OnRecordCreateRequest("settlements").BindFunc(func(e *core.RecordRequestEvent) error { + if err := e.Next(); err != nil { + return err + } + + actor := e.Auth + + group, err := e.App.FindRecordById("groups", e.Record.GetString("group")) if err != nil { - e.App.Logger().Error("webpush: failed to load subscriptions", "error", err) + e.App.Logger().Error("webpush: failed to load group for settlement notification", + "error", err, "group", e.Record.GetString("group")) + return nil + } + + amount := strconv.FormatFloat(e.Record.GetFloat("amount"), 'f', -1, 64) + currency := strings.ToUpper(e.Record.GetString("currency")) + + fromName := "Someone" + if fromUser, err := e.App.FindRecordById("users", e.Record.GetString("from")); err == nil { + if name := fromUser.GetString("name"); name != "" { + fromName = name + } else if email := fromUser.GetString("email"); email != "" { + fromName = email + } + } + + title := group.GetString("name") + body := fmt.Sprintf("%s paid %s %s", fromName, amount, currency) + targetURL := fmt.Sprintf("/group/%s", group.Id) + + actorID := "" + if actor != nil { + actorID = actor.Id + } + + for _, uid := range []string{e.Record.GetString("from"), e.Record.GetString("to")} { + if uid == "" || uid == actorID { + continue + } + sendNotificationToUser(e.App, keys, uid, title, body, targetURL) + } + + return nil + }) + + // Fans out notifications to all group members when recurring expenses are materialized + app.OnRecordAfterCreateSuccess("expenses").BindFunc(func(e *core.RecordEvent) error { + // Only handle recurring expenses (regular user-created expenses are handled above by OnRecordCreateRequest) + if e.Record.GetString("recurring") == "" { return e.Next() } - payload, err := json.Marshal(map[string]string{ - "title": e.Record.GetString("title"), - "body": e.Record.GetString("body"), - "url": e.Record.GetString("url"), - }) + group, err := e.App.FindRecordById("groups", e.Record.GetString("group")) if err != nil { + e.App.Logger().Error("webpush: failed to load group for recurring expense notification", + "error", err, "group", e.Record.GetString("group")) return e.Next() } - for _, subscription := range subscriptions { - routine.FireAndForget(func() { - sendPush(e.App, keys, subscription, payload) - }) + amount := strconv.FormatFloat(e.Record.GetFloat("amount"), 'f', -1, 64) + currency := strings.ToUpper(e.Record.GetString("currency")) + title := group.GetString("name") + body := fmt.Sprintf(`Recurring expense "%s" — %s %s`, e.Record.GetString("title"), amount, currency) + targetURL := fmt.Sprintf("/group/%s", group.Id) + + for _, memberId := range group.GetStringSlice("members") { + sendNotificationToUser(e.App, keys, memberId, title, body, targetURL) } return e.Next() }) } +func sendNotificationToUser(app core.App, keys *vapidKeys, userId, title, body, targetURL string) { + if keys == nil || userId == "" { + return + } + + subscriptions, err := findPushSubscriptions(app, userId) + if err != nil { + app.Logger().Error("webpush: failed to load subscriptions", "error", err, "user", userId) + return + } + + payload, err := json.Marshal(map[string]string{ + "title": title, + "body": body, + "url": targetURL, + }) + if err != nil { + app.Logger().Error("webpush: failed to marshal payload", "error", err) + return + } + + for _, subscription := range subscriptions { + routine.FireAndForget(func() { + sendPush(app, keys, subscription, payload) + }) + } +} + func findPushSubscriptions(app core.App, userId string) ([]*core.Record, error) { return app.FindRecordsByFilter( "push_subscriptions", From 1449ad062db2b49453347ae12c233c89e3f704e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Fri, 2 Oct 2026 22:48:18 +0200 Subject: [PATCH 2/3] update types without notifications --- src/types/pocketbase-types.gen.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/types/pocketbase-types.gen.ts b/src/types/pocketbase-types.gen.ts index e79d4a9..7602207 100644 --- a/src/types/pocketbase-types.gen.ts +++ b/src/types/pocketbase-types.gen.ts @@ -16,7 +16,6 @@ export const Collections = { Expenses: "expenses", Groups: "groups", Invites: "invites", - Notifications: "notifications", Projects: "projects", PushSubscriptions: "push_subscriptions", Rates: "rates", @@ -156,16 +155,6 @@ export type InvitesRecord = { updated: IsoAutoDateString } -export type NotificationsRecord = { - body?: string - created: IsoAutoDateString - id: string - title: string - updated: IsoAutoDateString - url?: string - user: RecordIdString -} - export type ProjectsRecord = { created: IsoAutoDateString group: RecordIdString @@ -277,7 +266,6 @@ export type CurrenciesResponse = Required & export type ExpensesResponse = Required & BaseSystemFields export type GroupsResponse = Required & BaseSystemFields export type InvitesResponse = Required & BaseSystemFields -export type NotificationsResponse = Required & BaseSystemFields export type ProjectsResponse = Required & BaseSystemFields export type PushSubscriptionsResponse = Required & BaseSystemFields export type RatesResponse = Required> & BaseSystemFields @@ -300,7 +288,6 @@ export type CollectionRecords = { expenses: ExpensesRecord groups: GroupsRecord invites: InvitesRecord - notifications: NotificationsRecord projects: ProjectsRecord push_subscriptions: PushSubscriptionsRecord rates: RatesRecord @@ -322,7 +309,6 @@ export type CollectionResponses = { expenses: ExpensesResponse groups: GroupsResponse invites: InvitesResponse - notifications: NotificationsResponse projects: ProjectsResponse push_subscriptions: PushSubscriptionsResponse rates: RatesResponse From ec4b48e3463cbecfce9e43485b60b238a9d39722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Sat, 3 Oct 2026 01:40:43 +0200 Subject: [PATCH 3/3] make sure notifications is sent after success --- webpush.go | 102 ++++++++++++++++++++++++----------------------------- 1 file changed, 46 insertions(+), 56 deletions(-) diff --git a/webpush.go b/webpush.go index dc4da54..8167932 100644 --- a/webpush.go +++ b/webpush.go @@ -161,58 +161,79 @@ func registerWebPush(app *pocketbase.PocketBase) { return se.Next() }) - // Fans out notifications to group members when expenses are created via the API + // Captures actor info while e.Auth is available during the API request app.OnRecordCreateRequest("expenses").BindFunc(func(e *core.RecordRequestEvent) error { - if err := e.Next(); err != nil { - return err - } - - actor := e.Auth - actorName := "Someone" - if actor != nil { + if actor := e.Auth; actor != nil { + e.Record.Set("@actorId", actor.Id) + actorName := "Someone" if name := actor.GetString("name"); name != "" { actorName = name } else if email := actor.GetString("email"); email != "" { actorName = email } + e.Record.Set("@actorName", actorName) } + return e.Next() + }) + // Captures actor ID while e.Auth is available during the API request + app.OnRecordCreateRequest("settlements").BindFunc(func(e *core.RecordRequestEvent) error { + if e.Auth != nil { + e.Record.Set("@actorId", e.Auth.Id) + } + return e.Next() + }) + + // Fans out notifications post-commit so rolled-back batch records do not trigger pushes + app.OnRecordAfterCreateSuccess("expenses").BindFunc(func(e *core.RecordEvent) error { group, err := e.App.FindRecordById("groups", e.Record.GetString("group")) if err != nil { e.App.Logger().Error("webpush: failed to load group for expense notification", "error", err, "group", e.Record.GetString("group")) - return nil + return e.Next() } amount := strconv.FormatFloat(e.Record.GetFloat("amount"), 'f', -1, 64) currency := strings.ToUpper(e.Record.GetString("currency")) - title := group.GetString("name") - body := fmt.Sprintf(`%s added "%s" — %s %s`, actorName, e.Record.GetString("title"), amount, currency) targetURL := fmt.Sprintf("/group/%s", group.Id) + members := group.GetStringSlice("members") + + // Recurring expense: notify all members (no actor to exclude) + if e.Record.GetString("recurring") != "" { + title := group.GetString("name") + body := fmt.Sprintf(`Recurring expense "%s" — %s %s`, e.Record.GetString("title"), amount, currency) + for _, memberId := range members { + sendNotificationToUser(e.App, keys, memberId, title, body, targetURL) + } + return e.Next() + } + + // User-created expense: notify all members except the actor + actorID := e.Record.GetString("@actorId") + actorName := e.Record.GetString("@actorName") + if actorName == "" { + actorName = "Someone" + } - for _, memberId := range group.GetStringSlice("members") { - if actor != nil && memberId == actor.Id { + title := group.GetString("name") + body := fmt.Sprintf(`%s added "%s" — %s %s`, actorName, e.Record.GetString("title"), amount, currency) + for _, memberId := range members { + if actorID != "" && memberId == actorID { continue } sendNotificationToUser(e.App, keys, memberId, title, body, targetURL) } - return nil + return e.Next() }) - // Fans out notifications to payer/recipient when settlements are created via the API - app.OnRecordCreateRequest("settlements").BindFunc(func(e *core.RecordRequestEvent) error { - if err := e.Next(); err != nil { - return err - } - - actor := e.Auth - + // Fans out notifications post-commit for settlements + app.OnRecordAfterCreateSuccess("settlements").BindFunc(func(e *core.RecordEvent) error { group, err := e.App.FindRecordById("groups", e.Record.GetString("group")) if err != nil { e.App.Logger().Error("webpush: failed to load group for settlement notification", "error", err, "group", e.Record.GetString("group")) - return nil + return e.Next() } amount := strconv.FormatFloat(e.Record.GetFloat("amount"), 'f', -1, 64) @@ -230,46 +251,15 @@ func registerWebPush(app *pocketbase.PocketBase) { title := group.GetString("name") body := fmt.Sprintf("%s paid %s %s", fromName, amount, currency) targetURL := fmt.Sprintf("/group/%s", group.Id) - - actorID := "" - if actor != nil { - actorID = actor.Id - } + actorID := e.Record.GetString("@actorId") for _, uid := range []string{e.Record.GetString("from"), e.Record.GetString("to")} { - if uid == "" || uid == actorID { + if uid == "" || (actorID != "" && uid == actorID) { continue } sendNotificationToUser(e.App, keys, uid, title, body, targetURL) } - return nil - }) - - // Fans out notifications to all group members when recurring expenses are materialized - app.OnRecordAfterCreateSuccess("expenses").BindFunc(func(e *core.RecordEvent) error { - // Only handle recurring expenses (regular user-created expenses are handled above by OnRecordCreateRequest) - if e.Record.GetString("recurring") == "" { - return e.Next() - } - - group, err := e.App.FindRecordById("groups", e.Record.GetString("group")) - if err != nil { - e.App.Logger().Error("webpush: failed to load group for recurring expense notification", - "error", err, "group", e.Record.GetString("group")) - return e.Next() - } - - amount := strconv.FormatFloat(e.Record.GetFloat("amount"), 'f', -1, 64) - currency := strings.ToUpper(e.Record.GetString("currency")) - title := group.GetString("name") - body := fmt.Sprintf(`Recurring expense "%s" — %s %s`, e.Record.GetString("title"), amount, currency) - targetURL := fmt.Sprintf("/group/%s", group.Id) - - for _, memberId := range group.GetStringSlice("members") { - sendNotificationToUser(e.App, keys, memberId, title, body, targetURL) - } - return e.Next() }) }