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/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
diff --git a/webpush.go b/webpush.go
index c709731..8167932 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,137 @@ func registerWebPush(app *pocketbase.PocketBase) {
return se.Next()
})
- app.OnRecordAfterCreateSuccess("notifications").BindFunc(func(e *core.RecordEvent) error {
- if keys == nil {
- return e.Next()
+ // Captures actor info while e.Auth is available during the API request
+ app.OnRecordCreateRequest("expenses").BindFunc(func(e *core.RecordRequestEvent) error {
+ 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()
+ })
- subscriptions, err := findPushSubscriptions(e.App, e.Record.GetString("user"))
+ // 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 subscriptions", "error", err)
+ e.App.Logger().Error("webpush: failed to load group for expense notification",
+ "error", err, "group", e.Record.GetString("group"))
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"),
- })
+ amount := strconv.FormatFloat(e.Record.GetFloat("amount"), 'f', -1, 64)
+ currency := strings.ToUpper(e.Record.GetString("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"
+ }
+
+ 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 e.Next()
+ })
+
+ // 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 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"))
+
+ 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 := e.Record.GetString("@actorId")
+
+ for _, uid := range []string{e.Record.GetString("from"), e.Record.GetString("to")} {
+ if uid == "" || (actorID != "" && uid == actorID) {
+ continue
+ }
+ sendNotificationToUser(e.App, keys, uid, 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",