Skip to content

Commit

Permalink
NK-600 Add runtime notifications list functions (#1275)
Browse files Browse the repository at this point in the history
Add runtime notifications list functions
Add cacheable param to core notifications list function. Cacheable true maintains the current client-facing API behavior where a cursor is always returned even if there are no results. With cacheable false the cursor is omitted when no next page exists.
Update gh golangci-lint version
  • Loading branch information
sesposito authored Oct 21, 2024
1 parent 7250d57 commit 5a8ff0c
Show file tree
Hide file tree
Showing 12 changed files with 220 additions and 47 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 1 addition & 20 deletions server/api_notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = &notificationCacheableCursor{}
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, true)
if err != nil {
return nil, status.Error(codes.Internal, "Error retrieving notifications.")
}
Expand Down
2 changes: 1 addition & 1 deletion server/core_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "", true)
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.")
Expand Down
64 changes: 46 additions & 18 deletions server/core_notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -199,12 +201,26 @@ 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, cacheable bool) (*api.NotificationList, error) {
var nc *notificationCacheableCursor
if cursor != "" {
nc = &notificationCacheableCursor{}
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 := " "
if limit > 0 {
params = append(params, limit)
params = append(params, limit+1)
limitQuery = " LIMIT $2"
}

Expand All @@ -227,7 +243,14 @@ ORDER BY create_time ASC, id ASC`+limitQuery, params...)

notifications := make([]*api.Notification, 0, limit)
var lastCreateTime int64
var resultCount int
var hasNextPage bool
for rows.Next() {
resultCount++
if resultCount > limit {
hasNextPage = true
break
}
no := &api.Notification{Persistent: true, CreateTime: &timestamppb.Timestamp{}}
var createTime pgtype.Timestamptz
if err := rows.Scan(&no.Id, &no.Subject, &no.Content, &no.Code, &no.SenderId, &createTime); err != nil {
Expand All @@ -248,28 +271,33 @@ ORDER BY create_time ASC, id ASC`+limitQuery, params...)
notificationList := &api.NotificationList{}
cursorBuf := new(bytes.Buffer)
if len(notifications) == 0 {
if len(cursor) > 0 {
notificationList.CacheableCursor = cursor
} else {
newCursor := &notificationCacheableCursor{NotificationID: nil, CreateTime: 0}
if cacheable {
if len(cursor) > 0 {
notificationList.CacheableCursor = cursor
} else {
newCursor := &notificationCacheableCursor{NotificationID: nil, CreateTime: 0}
if err := gob.NewEncoder(cursorBuf).Encode(newCursor); err != nil {
logger.Error("Could not create new cursor.", zap.Error(err))
return nil, err
}
notificationList.CacheableCursor = base64.RawURLEncoding.EncodeToString(cursorBuf.Bytes())
}
}
} else {
notificationList.Notifications = notifications
if cacheable || hasNextPage {
lastNotification := notifications[len(notifications)-1]
newCursor := &notificationCacheableCursor{
NotificationID: uuid.FromStringOrNil(lastNotification.Id).Bytes(),
CreateTime: lastCreateTime,
}
if err := gob.NewEncoder(cursorBuf).Encode(newCursor); err != nil {
logger.Error("Could not create new cursor.", zap.Error(err))
return nil, err
}

notificationList.CacheableCursor = base64.RawURLEncoding.EncodeToString(cursorBuf.Bytes())
}
} else {
lastNotification := notifications[len(notifications)-1]
newCursor := &notificationCacheableCursor{
NotificationID: uuid.FromStringOrNil(lastNotification.Id).Bytes(),
CreateTime: lastCreateTime,
}
if err := gob.NewEncoder(cursorBuf).Encode(newCursor); err != nil {
logger.Error("Could not create new cursor.", zap.Error(err))
return nil, err
}
notificationList.Notifications = notifications
notificationList.CacheableCursor = base64.RawURLEncoding.EncodeToString(cursorBuf.Bytes())
}

return notificationList, nil
Expand Down
3 changes: 1 addition & 2 deletions server/leaderboard_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -767,11 +767,10 @@ func (l *LocalLeaderboardCache) ListTournaments(now int64, categoryStart, catego
continue
}
if (endTime == 0 && leaderboard.EndTime != 0) || (endTime == -1 && (leaderboard.EndTime != 0 && leaderboard.EndTime < now)) || (endTime > 0 && (leaderboard.EndTime == 0 || leaderboard.EndTime > endTime)) {
// if (endTime == 0 && leaderboard.EndTime != 0) || (endTime == -1 && endTime < now) ||leaderboard.EndTime > endTime || leaderboard.EndTime == 0) || leaderboard.EndTime > endTime {
// SKIP tournaments where:
// - If end time filter is == 0, tournament end time is non-0.
// - If end time filter is default (show only ongoing/future tournaments) and tournament has ended.
// - If end time filter is set and tournament end time is below it.
// - If end time filter is set and tournament end time is past it.
continue
}

Expand Down
32 changes: 31 additions & 1 deletion server/runtime_go_nakama.go
Original file line number Diff line number Diff line change
Expand Up @@ -1780,6 +1780,36 @@ 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, false)
if err != nil {
return nil, "", err
}

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.
Expand Down Expand Up @@ -1899,7 +1929,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) {
Expand Down
73 changes: 70 additions & 3 deletions server/runtime_javascript_nakama.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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, false)
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.
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 5a8ff0c

Please sign in to comment.