From d5ab945ee7db261fe09ccdce42f388aed23a96a1 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 13:45:21 -0400 Subject: [PATCH] Web Push sender (Feature 19) (#164) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 7 + go.mod | 3 + go.sum | 69 +++ internal/db/push_subscriptions.sql.go | 83 ++++ internal/db/querier.go | 9 + internal/push/sender.go | 195 ++++++++ internal/push/sender_test.go | 520 ++++++++++++++++++++++ migrations/queries/push_subscriptions.sql | 24 + 8 files changed, 910 insertions(+) create mode 100644 internal/push/sender.go create mode 100644 internal/push/sender_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 231e070..ba295cd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -149,6 +149,13 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } + pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject) + if err != nil { + return err + } + if err := notifier.Register(notify.ChannelPush, pushSender); err != nil { + return err + } sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { diff --git a/go.mod b/go.mod index c5a9dce..83f4f71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest go 1.25.0 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coreos/go-oidc/v3 v3.21.0 github.com/go-chi/chi/v5 v5.3.1 github.com/oklog/ulid/v2 v2.1.2 @@ -14,10 +15,12 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index c8f3175..9911132 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -6,6 +8,9 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -21,18 +26,82 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= diff --git a/internal/db/push_subscriptions.sql.go b/internal/db/push_subscriptions.sql.go index 5b8ac62..583bae4 100644 --- a/internal/db/push_subscriptions.sql.go +++ b/internal/db/push_subscriptions.sql.go @@ -23,6 +23,89 @@ func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) ( return result.RowsAffected() } +const deletePushSubscriptionByID = `-- name: DeletePushSubscriptionByID :exec +DELETE FROM push_subscriptions +WHERE id = ? +` + +// Removes a subscription the push service reports gone (404 or 410). +func (q *Queries) DeletePushSubscriptionByID(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deletePushSubscriptionByID, id) + return err +} + +const listPushSubscriptions = `-- name: ListPushSubscriptions :many +SELECT id, endpoint, p256dh, auth +FROM push_subscriptions +ORDER BY created_at, id +` + +type ListPushSubscriptionsRow struct { + ID string + Endpoint string + P256dh string + Auth string +} + +// Every stored subscription with its keys, for the push sender, oldest +// first. +func (q *Queries) ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) { + rows, err := q.db.QueryContext(ctx, listPushSubscriptions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPushSubscriptionsRow + for rows.Next() { + var i ListPushSubscriptionsRow + if err := rows.Scan( + &i.ID, + &i.Endpoint, + &i.P256dh, + &i.Auth, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const recordPushFailure = `-- name: RecordPushFailure :exec +UPDATE push_subscriptions +SET failure_count = failure_count + 1 +WHERE id = ? +` + +// Counts a delivery to a subscription that the push service did not accept. +func (q *Queries) RecordPushFailure(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, recordPushFailure, id) + return err +} + +const recordPushSuccess = `-- name: RecordPushSuccess :exec +UPDATE push_subscriptions +SET last_success_at = ? +WHERE id = ? +` + +type RecordPushSuccessParams struct { + LastSuccessAt sql.NullString + ID string +} + +// Records that the push service accepted a notification for a subscription. +func (q *Queries) RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error { + _, err := q.db.ExecContext(ctx, recordPushSuccess, arg.LastSuccessAt, arg.ID) + return err +} + const upsertPushSubscription = `-- name: UpsertPushSubscription :one INSERT INTO push_subscriptions ( id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at diff --git a/internal/db/querier.go b/internal/db/querier.go index 5a3cfa9..1a1f6cd 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -68,6 +68,8 @@ type Querier interface { DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error) DeleteIntegrationsGoogle(ctx context.Context) error DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) + // Removes a subscription the push service reports gone (404 or 410). + DeletePushSubscriptionByID(ctx context.Context, id string) error DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error DeleteShoppingGroup(ctx context.Context, id string) error @@ -188,6 +190,9 @@ type Querier interface { // The planned dinners dated from from_date up to but not including // to_date, oldest first. Days with no dinner are left out. ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error) + // Every stored subscription with its keys, for the push sender, oldest + // first. + ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) // Reads for the AI household context block (internal/ai/harness). // The most recent names the household turned down, newest first, as // ListPassedNames has them. @@ -255,6 +260,10 @@ type Querier interface { // event id while its dinner stays the same; a new dinner clears it, since // Calendar no longer shows the day as planned. PutPlanDay(ctx context.Context, arg PutPlanDayParams) error + // Counts a delivery to a subscription that the push service did not accept. + RecordPushFailure(ctx context.Context, id string) error + // Records that the push service accepted a notification for a subscription. + RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error // Records a new draft over plan id: its status and note, raising its // revision. RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error diff --git a/internal/push/sender.go b/internal/push/sender.go new file mode 100644 index 0000000..26ecaf9 --- /dev/null +++ b/internal/push/sender.go @@ -0,0 +1,195 @@ +package push + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +// pushTTL is how long a push service keeps a notification for a device +// that is offline. +const pushTTL = 12 * time.Hour + +// maxDrainBytes bounds how much of a push service response body is read. +const maxDrainBytes = 4096 + +// notice is what the service worker shows (web/src/push/notification.ts). +type notice struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + Tag string `json:"tag"` +} + +// Sender is the notify.Sender of the push channel: it sends a notification +// to every stored subscription, signed with the VAPID key pair. +// +// Per subscription, a notification the push service accepts records +// last_success_at; one it refuses or cannot be sent increments +// failure_count, and a 404 or 410 deletes the subscription, which the push +// service no longer has. Send succeeds when at least one subscription +// accepted the notification, and when there are no subscriptions at all. +// +// Errors and logs name a subscription by its id, never by its endpoint, +// which is a capability URL. +type Sender struct { + st *store.Store + logger *slog.Logger + // subscriber is the VAPID subject as webpush-go takes it: an https:// + // URL, or the bare email address of a mailto: subject. + subscriber string + publicKey string + privateKey string + // client sends the push requests; tests replace it. + client webpush.HTTPClient + // now is the clock last_success_at is recorded on; tests replace it. + now func() time.Time +} + +// NewSender returns the push channel's sender over st, signing with the +// VAPID key pair publicKey and privateKey (vapid's encoding) and naming +// subject, a mailto: URI or https:// URL, as the contact. +func NewSender(st *store.Store, logger *slog.Logger, publicKey, privateKey, subject string) (*Sender, error) { + if st == nil { + return nil, errors.New("push: nil store") + } + if logger == nil { + return nil, errors.New("push: nil logger") + } + pub, err := vapid.ParsePublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("push: VAPID public key: %w", err) + } + priv, err := vapid.ParsePrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("push: VAPID private key: %w", err) + } + if err := vapid.CheckPair(pub, priv); err != nil { + return nil, fmt.Errorf("push: VAPID keys: %w", err) + } + // webpush-go prefixes mailto: to any subject not starting with https:, + // so a mailto: subject is handed over as its bare address. + var subscriber string + if addr, ok := strings.CutPrefix(subject, "mailto:"); ok && addr != "" { + subscriber = addr + } else if strings.HasPrefix(subject, "https://") { + subscriber = subject + } else { + return nil, fmt.Errorf("push: VAPID subject %q is not a mailto: URI or https:// URL", subject) + } + return &Sender{ + st: st, + logger: logger, + subscriber: subscriber, + publicKey: publicKey, + privateKey: privateKey, + client: &http.Client{}, + now: time.Now, + }, nil +} + +// Send sends m to every stored subscription. It returns nil when at least +// one accepted it or none is stored, and otherwise an error listing why +// each failed, so the outbox retries. A database error is returned at once. +func (s *Sender) Send(ctx context.Context, m notify.Message) error { + logger := s.logger.With("notification_id", m.ID, "event_key", m.EventKey) + subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx) + if err != nil { + return fmt.Errorf("push: list subscriptions: %w", err) + } + if len(subs) == 0 { + logger.InfoContext(ctx, "push notification has no subscriptions to send to; nothing sent") + return nil + } + message, err := json.Marshal(notice{Title: m.Payload.Title, Body: m.Payload.Body, URL: m.Payload.URL, Tag: m.Payload.Tag}) + if err != nil { + return fmt.Errorf("push: encode notification: %w", err) + } + + q := db.New(s.st.Writer) + delivered := 0 + var failures []string + for _, sub := range subs { + status, err := s.sendOne(ctx, message, sub) + switch { + case err == nil && status >= 200 && status < 300: + delivered++ + at := sql.NullString{String: timestamp(s.now()), Valid: true} + if err := q.RecordPushSuccess(ctx, db.RecordPushSuccessParams{LastSuccessAt: at, ID: sub.ID}); err != nil { + return fmt.Errorf("push: record success of subscription %s: %w", sub.ID, err) + } + continue + case err == nil && (status == http.StatusNotFound || status == http.StatusGone): + failures = append(failures, fmt.Sprintf("subscription %s gone (status %d), removed", sub.ID, status)) + logger.InfoContext(ctx, "push subscription gone; removed", "subscription_id", sub.ID, "status", status) + if err := q.DeletePushSubscriptionByID(ctx, sub.ID); err != nil { + return fmt.Errorf("push: remove gone subscription %s: %w", sub.ID, err) + } + continue + case err == nil: + failures = append(failures, fmt.Sprintf("subscription %s: status %d", sub.ID, status)) + default: + failures = append(failures, fmt.Sprintf("subscription %s: %v", sub.ID, err)) + } + if err := q.RecordPushFailure(ctx, sub.ID); err != nil { + return fmt.Errorf("push: record failure of subscription %s: %w", sub.ID, err) + } + } + if delivered == 0 { + return fmt.Errorf("push: no subscription accepted the notification: %s", strings.Join(failures, "; ")) + } + if len(failures) > 0 { + logger.WarnContext(ctx, "push notification not accepted by every subscription", + "delivered", delivered, "failures", strings.Join(failures, "; ")) + } + return nil +} + +// sendOne sends message to sub and returns the push service's status. Its +// error never quotes the endpoint. +func (s *Sender) sendOne(ctx context.Context, message []byte, sub db.ListPushSubscriptionsRow) (int, error) { + resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{ + Endpoint: sub.Endpoint, + Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh}, + }, &webpush.Options{ + HTTPClient: s.client, + Subscriber: s.subscriber, + TTL: int(pushTTL / time.Second), + VAPIDPublicKey: s.publicKey, + VAPIDPrivateKey: s.privateKey, + }) + if err != nil { + // A *url.Error quotes the request URL, the endpoint; keep only + // its cause. + var ue *url.Error + if errors.As(err, &ue) { + return 0, fmt.Errorf("request failed: %w", ue.Err) + } + return 0, err + } + defer resp.Body.Close() + // The status is the outcome; the body is read only so the connection + // can be reused. + io.CopyN(io.Discard, resp.Body, maxDrainBytes) + return resp.StatusCode, nil +} + +// timestamp returns t in the stored UTC RFC 3339 form. +func timestamp(t time.Time) string { + return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String() +} diff --git a/internal/push/sender_test.go b/internal/push/sender_test.go new file mode 100644 index 0000000..a3af8ed --- /dev/null +++ b/internal/push/sender_test.go @@ -0,0 +1,520 @@ +package push + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/binary" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +const subject = "mailto:kitchen@example.com" + +// sentAt is the sender's fake clock. +var sentAt = time.Date(2026, 10, 9, 17, 0, 0, 0, time.UTC) + +// device is a browser's push subscription with the private half of its +// keys, so a test can read what was sent to it. +type device struct { + path string + key *ecdh.PrivateKey + auth []byte +} + +func newDevice(t *testing.T, path string) device { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + auth := make([]byte, 16) + rand.Read(auth) + return device{path: path, key: k, auth: auth} +} + +// request is one request the push service received. +type request struct { + path string + header http.Header + body []byte +} + +// pushService is an httptest push service answering each path with its +// status. +type pushService struct { + srv *httptest.Server + status map[string]int + + mu sync.Mutex + got []request +} + +func newPushService(t *testing.T, status map[string]int) *pushService { + t.Helper() + ps := &pushService{status: status} + ps.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + ps.mu.Lock() + ps.got = append(ps.got, request{path: r.URL.Path, header: r.Header.Clone(), body: body}) + ps.mu.Unlock() + code, ok := ps.status[r.URL.Path] + if !ok { + t.Errorf("unexpected push to %s", r.URL.Path) + code = http.StatusBadRequest + } + w.WriteHeader(code) + })) + t.Cleanup(ps.srv.Close) + return ps +} + +func (ps *pushService) requests() []request { + ps.mu.Lock() + defer ps.mu.Unlock() + return append([]request(nil), ps.got...) +} + +// newTestSender returns a sender over a migrated store with the devices +// subscribed to ps, and the VAPID key pair it signs with. +func newTestSender(t *testing.T, ps *pushService, devices ...device) (*Sender, *store.Store, vapid.KeyPair) { + t.Helper() + svc, st := newTestService(t) + for _, d := range devices { + _, err := svc.Subscribe(context.Background(), SubscribeRequest{ + Endpoint: ps.srv.URL + d.path, + P256dh: keyEncoding.EncodeToString(d.key.PublicKey().Bytes()), + Auth: keyEncoding.EncodeToString(d.auth), + }) + if err != nil { + t.Fatal(err) + } + } + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + s, err := NewSender(st, slog.New(slog.NewTextHandler(io.Discard, nil)), pair.PublicKey, pair.PrivateKey, subject) + if err != nil { + t.Fatal(err) + } + s.client = ps.srv.Client() + s.now = func() time.Time { return sentAt } + return s, st, pair +} + +func message() notify.Message { + return notify.Message{ + ID: "01NOTIFICATION", + EventKey: "plan_ready:2026-10-12", + Channel: notify.ChannelPush, + Payload: notify.Payload{ + Event: notify.PlanReady, + Title: "This week's plan is ready", + Body: "Five dinners on the Draft board.", + URL: "/plan", + Tag: "plan_ready:2026-10-12", + Week: "2026-10-12", + }, + Attempt: 1, + } +} + +// pushState is a subscription's delivery record. +type pushState struct { + failures int + lastSuccess sql.NullString +} + +// states returns the delivery record of each stored subscription by path. +func states(t *testing.T, st *store.Store, ps *pushService) map[string]pushState { + t.Helper() + rs, err := st.Reader.Query(`SELECT endpoint, failure_count, last_success_at FROM push_subscriptions`) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + got := map[string]pushState{} + for rs.Next() { + var ep string + var s pushState + if err := rs.Scan(&ep, &s.failures, &s.lastSuccess); err != nil { + t.Fatal(err) + } + got[strings.TrimPrefix(ep, ps.srv.URL)] = s + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return got +} + +// decrypt reads an aes128gcm push message body (RFC 8291) as d. +func decrypt(t *testing.T, d device, body []byte) []byte { + t.Helper() + if len(body) < 21 { + t.Fatalf("body of %d bytes has no header", len(body)) + } + salt, rs, idLen := body[:16], binary.BigEndian.Uint32(body[16:20]), int(body[20]) + if rs != 4096 || idLen != 65 { + t.Fatalf("record size %d, key id length %d", rs, idLen) + } + asPublic, ciphertext := body[21:21+idLen], body[21+idLen:] + peer, err := ecdh.P256().NewPublicKey(asPublic) + if err != nil { + t.Fatal(err) + } + secret, err := d.key.ECDH(peer) + if err != nil { + t.Fatal(err) + } + info := append(append([]byte("WebPush: info\x00"), d.key.PublicKey().Bytes()...), asPublic...) + ikm, err := hkdf.Key(sha256.New, secret, d.auth, string(info), 32) + if err != nil { + t.Fatal(err) + } + cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16) + if err != nil { + t.Fatal(err) + } + nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12) + if err != nil { + t.Fatal(err) + } + block, err := aes.NewCipher(cek) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + plain = bytes.TrimRight(plain, "\x00") + if len(plain) == 0 || plain[len(plain)-1] != 0x02 { + t.Fatal("no last-record delimiter") + } + return plain[:len(plain)-1] +} + +// vapidClaims checks the VAPID Authorization header names pair's public key +// and returns its token's claims (the signature is not checked). +func vapidClaims(t *testing.T, header string, pair vapid.KeyPair) map[string]any { + t.Helper() + token, key, ok := strings.Cut(strings.TrimPrefix(header, "vapid t="), ", k=") + if !ok || !strings.HasPrefix(header, "vapid t=") { + t.Fatalf("Authorization = %q, want vapid t=…, k=…", header) + } + if key != pair.PublicKey { + t.Errorf("k = %q, want the VAPID public key %q", key, pair.PublicKey) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token has %d parts", len(parts)) + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatal(err) + } + var claims map[string]any + if err := json.Unmarshal(raw, &claims); err != nil { + t.Fatal(err) + } + return claims +} + +func TestSendDeliversToEveryDevice(t *testing.T) { + phone, tablet := newDevice(t, "/push/phone"), newDevice(t, "/push/tablet") + ps := newPushService(t, map[string]int{phone.path: http.StatusCreated, tablet.path: http.StatusCreated}) + s, st, pair := newTestSender(t, ps, phone, tablet) + + if err := s.Send(context.Background(), message()); err != nil { + t.Fatalf("Send: %v", err) + } + + reqs := ps.requests() + if len(reqs) != 2 { + t.Fatalf("push service got %d requests, want 2", len(reqs)) + } + for _, r := range reqs { + d := phone + if r.path == tablet.path { + d = tablet + } + if got := r.header.Get("TTL"); got != "43200" { + t.Errorf("%s: TTL = %q, want 43200 (12 hours)", r.path, got) + } + if got := r.header.Get("Content-Encoding"); got != "aes128gcm" { + t.Errorf("%s: Content-Encoding = %q", r.path, got) + } + claims := vapidClaims(t, r.header.Get("Authorization"), pair) + if claims["sub"] != subject { + t.Errorf("%s: VAPID sub = %v, want %s", r.path, claims["sub"], subject) + } + if claims["aud"] != ps.srv.URL { + t.Errorf("%s: VAPID aud = %v, want %s", r.path, claims["aud"], ps.srv.URL) + } + var got map[string]string + if err := json.Unmarshal(decrypt(t, d, r.body), &got); err != nil { + t.Fatal(err) + } + want := map[string]string{ + "title": "This week's plan is ready", + "body": "Five dinners on the Draft board.", + "url": "/plan", + "tag": "plan_ready:2026-10-12", + } + if len(got) != len(want) { + t.Errorf("%s: payload = %v, want %v", r.path, got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("%s: payload %s = %q, want %q", r.path, k, got[k], v) + } + } + } + + want := pushState{lastSuccess: sql.NullString{String: "2026-10-09T17:00:00Z", Valid: true}} + for path, got := range states(t, st, ps) { + if got.failures != want.failures || got.lastSuccess.Valid != want.lastSuccess.Valid || + !sameInstant(t, got.lastSuccess.String, want.lastSuccess.String) { + t.Errorf("%s: state = %+v, want %+v", path, got, want) + } + } +} + +func sameInstant(t *testing.T, a, b string) bool { + t.Helper() + ta, err := time.Parse(time.RFC3339Nano, a) + if err != nil { + t.Fatalf("last_success_at %q is not RFC 3339: %v", a, err) + } + tb, err := time.Parse(time.RFC3339Nano, b) + if err != nil { + t.Fatal(err) + } + return ta.Equal(tb) && strings.HasSuffix(a, "Z") +} + +func TestSendRemovesGoneSubscriptions(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusGone} { + t.Run(http.StatusText(status), func(t *testing.T) { + gone := newDevice(t, "/push/gone") + ps := newPushService(t, map[string]int{gone.path: status}) + s, st, _ := newTestSender(t, ps, gone) + + err := s.Send(context.Background(), message()) + if err == nil { + t.Fatal("Send with only a gone subscription succeeded, want an error so the outbox retries") + } + if strings.Contains(err.Error(), ps.srv.URL) { + t.Errorf("error %q quotes the endpoint", err) + } + if got := states(t, st, ps); len(got) != 0 { + t.Errorf("subscriptions = %v, want the gone one removed", got) + } + }) + } +} + +func TestSendSucceedsWhenOneDeviceAccepts(t *testing.T) { + ok, gone, broken := newDevice(t, "/push/ok"), newDevice(t, "/push/gone"), newDevice(t, "/push/broken") + ps := newPushService(t, map[string]int{ + ok.path: http.StatusCreated, gone.path: http.StatusGone, broken.path: http.StatusInternalServerError, + }) + s, st, _ := newTestSender(t, ps, ok, gone, broken) + + if err := s.Send(context.Background(), message()); err != nil { + t.Fatalf("Send: %v", err) + } + got := states(t, st, ps) + if len(got) != 2 { + t.Fatalf("subscriptions = %v, want ok and broken kept, gone removed", got) + } + if s := got[ok.path]; !s.lastSuccess.Valid || s.failures != 0 { + t.Errorf("ok = %+v, want last_success_at set and no failures", s) + } + if s := got[broken.path]; s.lastSuccess.Valid || s.failures != 1 { + t.Errorf("broken = %+v, want one failure and no success", s) + } +} + +func TestSendServerErrorIsRetryable(t *testing.T) { + broken := newDevice(t, "/push/broken") + ps := newPushService(t, map[string]int{broken.path: http.StatusInternalServerError}) + s, st, _ := newTestSender(t, ps, broken) + + for want := 1; want <= 2; want++ { + err := s.Send(context.Background(), message()) + if err == nil { + t.Fatal("Send with only a failing subscription succeeded, want an error so the outbox retries") + } + if !strings.Contains(err.Error(), "status 500") || strings.Contains(err.Error(), ps.srv.URL) { + t.Errorf("error = %q, want the status and no endpoint", err) + } + got := states(t, st, ps)[broken.path] + if got.failures != want || got.lastSuccess.Valid { + t.Errorf("after send %d: state = %+v, want failure_count %d and no success", want, got, want) + } + } +} + +func TestSendTransportErrorDoesNotQuoteEndpoint(t *testing.T) { + d := newDevice(t, "/push/secret-token") + ps := newPushService(t, map[string]int{}) + s, st, _ := newTestSender(t, ps, d) + ps.srv.Close() + + err := s.Send(context.Background(), message()) + if err == nil { + t.Fatal("Send to a closed push service succeeded") + } + if strings.Contains(err.Error(), "secret-token") { + t.Errorf("error %q quotes the endpoint", err) + } + if got := states(t, st, ps)[d.path]; got.failures != 1 { + t.Errorf("state = %+v, want one failure", got) + } +} + +func TestSendWithNoSubscriptionsIsSent(t *testing.T) { + ps := newPushService(t, map[string]int{}) + s, _, _ := newTestSender(t, ps) + + if err := s.Send(context.Background(), message()); err != nil { + t.Fatalf("Send with no subscriptions = %v, want nil", err) + } + if n := len(ps.requests()); n != 0 { + t.Errorf("push service got %d requests, want none", n) + } +} + +// TestOutboxDelivery runs the sender under the notify worker and checks the +// outbox row each outcome leaves. +func TestOutboxDelivery(t *testing.T) { + cases := []struct { + name string + status map[string]int // by device path; nil for no devices + wantStatus string + wantError bool + }{ + {"delivered", map[string]int{"/push/ok": http.StatusCreated}, "sent", false}, + {"server error", map[string]int{"/push/broken": http.StatusInternalServerError}, "pending", true}, + {"no subscriptions", nil, "sent", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ps := newPushService(t, tc.status) + var devices []device + for path := range tc.status { + devices = append(devices, newDevice(t, path)) + } + s, st, _ := newTestSender(t, ps, devices...) + ctx, cancel := context.WithCancel(context.Background()) + if err := household.SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1, chat = 0 WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + n, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + if err := n.Register(notify.ChannelPush, s); err != nil { + t.Fatal(err) + } + if err := n.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { cancel(); n.Wait() }() + if err := n.Emit(ctx, notify.Event{ + Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "This week's plan is ready", URL: "/plan", + }); err != nil { + t.Fatal(err) + } + + var status string + var attempts int + var lastError sql.NullString + deadline := time.Now().Add(10 * time.Second) + for { + err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox + WHERE event_key = 'plan_ready:2026-10-12' AND channel = 'push'`).Scan(&status, &attempts, &lastError) + if err != nil { + t.Fatal(err) + } + if attempts > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("the worker did not deliver the notification") + } + time.Sleep(10 * time.Millisecond) + } + if status != tc.wantStatus || attempts != 1 || lastError.Valid != tc.wantError { + t.Errorf("outbox row: status %s, attempts %d, last_error %v; want %s, 1, error %v", + status, attempts, lastError, tc.wantStatus, tc.wantError) + } + }) + } +} + +func TestNewSenderRefusesBadConfig(t *testing.T) { + _, st := newTestService(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + other, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + cases := map[string]struct{ public, private, subject string }{ + "bad public key": {"nope", pair.PrivateKey, subject}, + "bad private key": {pair.PublicKey, "nope", subject}, + "mismatched pair": {pair.PublicKey, other.PrivateKey, subject}, + "bad subject": {pair.PublicKey, pair.PrivateKey, "kitchen@example.com"}, + "empty mailto": {pair.PublicKey, pair.PrivateKey, "mailto:"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + _, err := NewSender(st, logger, c.public, c.private, c.subject) + if err == nil { + t.Fatal("NewSender succeeded") + } + if strings.Contains(err.Error(), pair.PrivateKey) || strings.Contains(err.Error(), other.PrivateKey) { + t.Errorf("error %q quotes the private key", err) + } + }) + } + if _, err := NewSender(st, logger, pair.PublicKey, pair.PrivateKey, "https://kitchen.example.com/contact"); err != nil { + t.Errorf("https subject: %v", err) + } +} diff --git a/migrations/queries/push_subscriptions.sql b/migrations/queries/push_subscriptions.sql index 5042e46..847cb91 100644 --- a/migrations/queries/push_subscriptions.sql +++ b/migrations/queries/push_subscriptions.sql @@ -18,3 +18,27 @@ RETURNING *; -- name: DeletePushSubscription :execrows DELETE FROM push_subscriptions WHERE endpoint = ?; + +-- name: ListPushSubscriptions :many +-- Every stored subscription with its keys, for the push sender, oldest +-- first. +SELECT id, endpoint, p256dh, auth +FROM push_subscriptions +ORDER BY created_at, id; + +-- name: RecordPushSuccess :exec +-- Records that the push service accepted a notification for a subscription. +UPDATE push_subscriptions +SET last_success_at = ? +WHERE id = ?; + +-- name: RecordPushFailure :exec +-- Counts a delivery to a subscription that the push service did not accept. +UPDATE push_subscriptions +SET failure_count = failure_count + 1 +WHERE id = ?; + +-- name: DeletePushSubscriptionByID :exec +-- Removes a subscription the push service reports gone (404 or 410). +DELETE FROM push_subscriptions +WHERE id = ?;