diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 0c8f70eea..2fd014f06 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,6 +20,6 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: - version: v1.54 + version: v1.61 only-new-issues: true args: --timeout=10m diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e502a66..ed2918745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project are documented below. The format is based on [keep a changelog](http://keepachangelog.com) and this project uses [semantic versioning](http://semver.org). ## [Unreleased] +### Added +- New runtime function to list user notifications. + ### Changed - Increased limit on runtimes group users list functions. diff --git a/go.mod b/go.mod index c9b2c8bda..5bd935fcf 100644 --- a/go.mod +++ b/go.mod @@ -74,3 +74,5 @@ require ( golang.org/x/text v0.16.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect ) + +replace github.com/heroiclabs/nakama-common => ../nakama-common diff --git a/server/api_notification.go b/server/api_notification.go index 80f1d63c8..e57133d24 100644 --- a/server/api_notification.go +++ b/server/api_notification.go @@ -15,11 +15,7 @@ package server import ( - "bytes" "context" - "encoding/base64" - "encoding/gob" - "github.com/gofrs/uuid/v5" "github.com/heroiclabs/nakama-common/api" "go.uber.org/zap" @@ -62,22 +58,7 @@ func (s *ApiServer) ListNotifications(ctx context.Context, in *api.ListNotificat limit = int(in.GetLimit().Value) } - cursor := in.GetCacheableCursor() - var nc *notificationCacheableCursor - if cursor != "" { - nc = ¬ificationCacheableCursor{} - cb, err := base64.RawURLEncoding.DecodeString(cursor) - if err != nil { - s.logger.Warn("Could not base64 decode notification cursor.", zap.String("cursor", cursor)) - return nil, status.Error(codes.InvalidArgument, "Malformed cursor was used.") - } - if err := gob.NewDecoder(bytes.NewReader(cb)).Decode(nc); err != nil { - s.logger.Warn("Could not decode notification cursor.", zap.String("cursor", cursor)) - return nil, status.Error(codes.InvalidArgument, "Malformed cursor was used.") - } - } - - notificationList, err := NotificationList(ctx, s.logger, s.db, userID, limit, cursor, nc) + notificationList, err := NotificationList(ctx, s.logger, s.db, userID, limit, in.CacheableCursor) if err != nil { return nil, status.Error(codes.Internal, "Error retrieving notifications.") } diff --git a/server/core_account.go b/server/core_account.go index ddf9d01b8..95f85b891 100644 --- a/server/core_account.go +++ b/server/core_account.go @@ -408,7 +408,7 @@ func ExportAccount(ctx context.Context, logger *zap.Logger, db *sql.DB, userID u } // Notifications. - notifications, err := NotificationList(ctx, logger, db, userID, 0, "", nil) + notifications, err := NotificationList(ctx, logger, db, userID, 0, "") if err != nil { logger.Error("Could not fetch notifications", zap.Error(err), zap.String("user_id", userID.String())) return nil, status.Error(codes.Internal, "An error occurred while trying to export user data.") diff --git a/server/core_notification.go b/server/core_notification.go index 694afe83f..a5bad16d7 100644 --- a/server/core_notification.go +++ b/server/core_notification.go @@ -24,6 +24,8 @@ import ( "errors" "fmt" "github.com/heroiclabs/nakama-common/runtime" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "time" "github.com/gofrs/uuid/v5" @@ -199,7 +201,21 @@ func NotificationSendAll(ctx context.Context, logger *zap.Logger, db *sql.DB, go return nil } -func NotificationList(ctx context.Context, logger *zap.Logger, db *sql.DB, userID uuid.UUID, limit int, cursor string, nc *notificationCacheableCursor) (*api.NotificationList, error) { +func NotificationList(ctx context.Context, logger *zap.Logger, db *sql.DB, userID uuid.UUID, limit int, cursor string) (*api.NotificationList, error) { + var nc *notificationCacheableCursor + if cursor != "" { + nc = ¬ificationCacheableCursor{} + cb, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + logger.Warn("Could not base64 decode notification cursor.", zap.String("cursor", cursor)) + return nil, status.Error(codes.InvalidArgument, "Malformed cursor was used.") + } + if err = gob.NewDecoder(bytes.NewReader(cb)).Decode(nc); err != nil { + logger.Warn("Could not decode notification cursor.", zap.String("cursor", cursor)) + return nil, status.Error(codes.InvalidArgument, "Malformed cursor was used.") + } + } + params := []interface{}{userID} limitQuery := " " diff --git a/server/runtime_go_nakama.go b/server/runtime_go_nakama.go index e8ef9df38..4aac2f813 100644 --- a/server/runtime_go_nakama.go +++ b/server/runtime_go_nakama.go @@ -1780,6 +1780,41 @@ func (n *RuntimeGoNakamaModule) NotificationsGetId(ctx context.Context, userID s return NotificationsGetId(ctx, n.logger, n.db, userID, ids...) } +// @group notifications +// @summary List notifications by user id. +// @param ctx(type=context.Context) The context object represents information about the server and requester. +// @param userID(type=string) Optional userID to scope results to that user only. +// @param limit(type=int) Limit number of results. Must be a value between 1 and 1000. +// @param cursor(type=string) Pagination cursor from previous result. Don't set to start fetching from the beginning. +// @return notifications(*api.NotificationList) A list of notifications. +// @return error(error) An optional error value if an error occurred. +func (n *RuntimeGoNakamaModule) NotificationsList(ctx context.Context, userID string, limit int, cursor string) ([]*api.Notification, string, error) { + if userID == "" { + return nil, "", errors.New("expects a valid user id") + } + + if limit < 0 || limit > 1000 { + return nil, "", errors.New("expects limit to be 0-100") + } + + uid, err := uuid.FromString(userID) + if err != nil { + return nil, "", errors.New("expects a valid user id") + } + + list, err := NotificationList(ctx, n.logger, n.db, uid, limit, cursor) + if err != nil { + return nil, "", err + } + + if len(list.Notifications) == 0 { + // Cursor is returned even if there are no more entries so return no cursor to indicate we've gone through all. + return []*api.Notification{}, "", nil + } + + return list.Notifications, list.CacheableCursor, nil +} + // @group notifications // @summary Delete notifications by their id. // @param ctx(type=context.Context) The context object represents information about the server and requester. @@ -1899,7 +1934,7 @@ func (n *RuntimeGoNakamaModule) WalletLedgerUpdate(ctx context.Context, itemID s // @param ctx(type=context.Context) The context object represents information about the server and requester. // @param userId(type=string) The ID of the user to list wallet updates for. // @param limit(type=int, optional=true, default=100) Limit number of results. -// @param cursor(type=string, optional=true, default="") Pagination cursor from previous result. Don't set to start fetching from the beginning. +// @param cursor(type=string, default="") Pagination cursor from previous result. Don't set to start fetching from the beginning. // @return runtimeItems([]runtime.WalletLedgerItem) A Go slice containing wallet entries with Id, UserId, CreateTime, UpdateTime, Changeset, Metadata parameters. // @return error(error) An optional error value if an error occurred. func (n *RuntimeGoNakamaModule) WalletLedgerList(ctx context.Context, userID string, limit int, cursor string) ([]runtime.WalletLedgerItem, string, error) { diff --git a/server/runtime_javascript_nakama.go b/server/runtime_javascript_nakama.go index 3d0f5b618..885b32d9f 100644 --- a/server/runtime_javascript_nakama.go +++ b/server/runtime_javascript_nakama.go @@ -224,8 +224,9 @@ func (n *runtimeJavascriptNakamaModule) mappings(r *goja.Runtime) map[string]fun "matchList": n.matchList(r), "matchSignal": n.matchSignal(r), "notificationSend": n.notificationSend(r), - "notificationsSend": n.notificationsSend(r), "notificationSendAll": n.notificationSendAll(r), + "notificationsList": n.notificationsList(r), + "notificationsSend": n.notificationsSend(r), "notificationsDelete": n.notificationsDelete(r), "notificationsGetId": n.notificationsGetId(r), "notificationsDeleteId": n.notificationsDeleteId(r), @@ -3703,6 +3704,72 @@ func (n *runtimeJavascriptNakamaModule) notificationSend(r *goja.Runtime) func(g } } +// @group notifications +// @summary List notifications by user id. +// @param userID(type=string) Optional userID to scope results to that user only. +// @param limit(type=int, optiona=true, default=100) Limit number of results. Must be a value between 1 and 1000. +// @param cursor(type=string, optional=true, default="") Pagination cursor from previous result. Don't set to start fetching from the beginning. +// @return notifications(nkruntime.NotificationList) A list of notifications. +// @return error(error) An optional error value if an error occurred. +func (n *runtimeJavascriptNakamaModule) notificationsList(r *goja.Runtime) func(goja.FunctionCall) goja.Value { + return func(f goja.FunctionCall) goja.Value { + userIDString := getJsString(r, f.Argument(0)) + if userIDString == "" { + panic(r.ToValue(r.NewTypeError("expects user id"))) + } + userID, err := uuid.FromString(userIDString) + if err != nil { + panic(r.NewTypeError("invalid user id")) + } + + limit := 100 + if f.Argument(1) != goja.Undefined() && f.Argument(1) != goja.Null() { + limit = int(getJsInt(r, f.Argument(1))) + if limit < 1 || limit > 1000 { + panic(r.ToValue(r.NewTypeError("expects limit between 1 and 1000"))) + } + } + + cursor := "" + if f.Argument(2) != goja.Undefined() && f.Argument(2) != goja.Null() { + cursor = getJsString(r, f.Argument(2)) + } + + list, err := NotificationList(n.ctx, n.logger, n.db, userID, limit, cursor) + if err != nil { + panic(r.ToValue(r.NewGoError(fmt.Errorf("failed to list notifications: %s", err.Error())))) + } + + if len(list.Notifications) == 0 { + list.CacheableCursor = "" + } + + notObjs := make([]any, 0, len(list.Notifications)) + for _, n := range list.Notifications { + no := r.NewObject() + _ = no.Set("id", n.Id) + _ = no.Set("subject", n.Subject) + _ = no.Set("content", n.Content) + _ = no.Set("code", n.Code) + _ = no.Set("senderId", n.SenderId) + _ = no.Set("persistent", n.Persistent) + _ = no.Set("createTime", n.CreateTime.Seconds) + + notObjs = append(notObjs, no) + } + + outObj := r.NewObject() + _ = outObj.Set("notifications", r.NewArray(notObjs...)) + if list.CacheableCursor != "" { + _ = outObj.Set("cursor", list.CacheableCursor) + } else { + _ = outObj.Set("cursor", goja.Null()) + } + + return outObj + } +} + // @group notifications // @summary Send one or more in-app notifications to a user. // @param notifications(type=any[]) A list of notifications to be sent together. @@ -3981,13 +4048,13 @@ func (n *runtimeJavascriptNakamaModule) notificationsGetId(r *goja.Runtime) func notifObj := r.NewObject() _ = notifObj.Set("id", no.Id) - _ = notifObj.Set("user_id", no.UserID) + _ = notifObj.Set("userId", no.UserID) _ = notifObj.Set("subject", no.Subject) _ = notifObj.Set("persistent", no.Persistent) _ = notifObj.Set("content", no.Content) _ = notifObj.Set("code", no.Code) _ = notifObj.Set("sender", no.Sender) - _ = notifObj.Set("create_time", no.CreateTime.Seconds) + _ = notifObj.Set("createTime", no.CreateTime.Seconds) _ = notifObj.Set("persistent", no.Persistent) notifications = append(notifications, notifObj) diff --git a/server/runtime_lua_nakama.go b/server/runtime_lua_nakama.go index 6edf7f62f..fbba054eb 100644 --- a/server/runtime_lua_nakama.go +++ b/server/runtime_lua_nakama.go @@ -243,6 +243,7 @@ func (n *RuntimeLuaNakamaModule) Loader(l *lua.LState) int { "notification_send": n.notificationSend, "notifications_send": n.notificationsSend, "notification_send_all": n.notificationSendAll, + "notifications_list": n.notificationsList, "notifications_delete": n.notificationsDelete, "notifications_get_id": n.notificationsGetId, "notifications_delete_id": n.notificationsDeleteId, @@ -5198,6 +5199,66 @@ func (n *RuntimeLuaNakamaModule) notificationSendAll(l *lua.LState) int { return 0 } +// @group notifications +// @summary List notifications by user id. +// @param userID(type=string) Optional userID to scope results to that user only. +// @param limit(type=int, optiona=true, default=100) Limit number of results. Must be a value between 1 and 1000. +// @param cursor(type=string, optional=true, default="") Pagination cursor from previous result. Don't set to start fetching from the beginning. +// @return notifications(table) A list of notifications. +// @return cursor(string) A cursor to fetch the next page of results. +// @return error(error) An optional error value if an error occurred. +func (n *RuntimeLuaNakamaModule) notificationsList(l *lua.LState) int { + u := l.CheckString(1) + userID, err := uuid.FromString(u) + if err != nil { + l.ArgError(1, "expects user_id to be a valid uuid") + return 0 + } + + limit := l.OptInt(2, 100) + if limit < 1 || limit > 1000 { + l.ArgError(2, "expects limit to be value between 1 and 1000") + return 0 + } + + cursor := l.OptString(3, "") + + list, err := NotificationList(l.Context(), n.logger, n.db, userID, limit, cursor) + if err != nil { + l.RaiseError("failed to list notifications: %s", err.Error()) + return 0 + } + + if len(list.Notifications) == 0 { + list.CacheableCursor = "" + } + + notifsTable := l.CreateTable(len(list.Notifications), 0) + for i, no := range list.Notifications { + noTable := l.CreateTable(0, len(list.Notifications)) + + noTable.RawSetString("id", lua.LString(no.Id)) + noTable.RawSetString("subject", lua.LString(no.Subject)) + noTable.RawSetString("content", lua.LString(no.Content)) + noTable.RawSetString("code", lua.LNumber(no.Code)) + noTable.RawSetString("senderId", lua.LString(no.SenderId)) + noTable.RawSetString("persistent", lua.LBool(no.Persistent)) + noTable.RawSetString("createTime", lua.LNumber(no.CreateTime.Seconds)) + + notifsTable.RawSetInt(i+1, noTable) + } + + l.Push(notifsTable) + + if list.CacheableCursor != "" { + l.Push(lua.LString(list.CacheableCursor)) + } else { + l.Push(lua.LNil) + } + + return 2 +} + // @group notifications // @summary Delete one or more in-app notifications. // @param notifications(type=table) A list of notifications to be deleted. diff --git a/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go b/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go index 73b13cccc..63f7a732d 100644 --- a/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go +++ b/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go @@ -1102,6 +1102,7 @@ type NakamaModule interface { MatchSignal(ctx context.Context, id string, data string) (string, error) NotificationSend(ctx context.Context, userID, subject string, content map[string]interface{}, code int, sender string, persistent bool) error + NotificationsList(ctx context.Context, userID string, limit int, cursor string) ([]*api.Notification, string, error) NotificationsSend(ctx context.Context, notifications []*NotificationSend) error NotificationSendAll(ctx context.Context, subject string, content map[string]interface{}, code int, persistent bool) error NotificationsDelete(ctx context.Context, notifications []*NotificationDelete) error diff --git a/vendor/modules.txt b/vendor/modules.txt index 25aba4e71..1473c7c6f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -139,7 +139,7 @@ github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/internal/genopena github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options github.com/grpc-ecosystem/grpc-gateway/v2/runtime github.com/grpc-ecosystem/grpc-gateway/v2/utilities -# github.com/heroiclabs/nakama-common v1.33.1-0.20240920140332-3cdf52bdf781 +# github.com/heroiclabs/nakama-common v1.33.1-0.20240920140332-3cdf52bdf781 => ../nakama-common ## explicit; go 1.19 github.com/heroiclabs/nakama-common/api github.com/heroiclabs/nakama-common/rtapi @@ -417,3 +417,4 @@ gopkg.in/natefinch/lumberjack.v2 # gopkg.in/yaml.v3 v3.0.1 ## explicit gopkg.in/yaml.v3 +# github.com/heroiclabs/nakama-common => ../nakama-common