From c554479604aea02b5ba85bf779e4de9572fa15f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Fri, 5 Dec 2025 16:59:56 +0100 Subject: [PATCH] Implement /deliveries route --- scnserver/api/handler/apiMessage.go | 59 +++++++++++++ scnserver/api/router.go | 1 + scnserver/db/impl/primary/deliveries.go | 12 ++- scnserver/test/message_test.go | 108 ++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 1 deletion(-) diff --git a/scnserver/api/handler/apiMessage.go b/scnserver/api/handler/apiMessage.go index 2fd4b2d..d2662e3 100644 --- a/scnserver/api/handler/apiMessage.go +++ b/scnserver/api/handler/apiMessage.go @@ -271,6 +271,65 @@ func (h APIHandler) GetMessage(pctx ginext.PreContext) ginext.HTTPResponse { }) } +// ListMessageDeliveries swaggerdoc +// +// @Summary List deliveries for a message +// @Description The user must own the channel and request the resource with the ADMIN Key +// @ID api-messages-deliveries +// @Tags API-v2 +// +// @Param mid path string true "MessageID" +// +// @Success 200 {object} handler.ListMessageDeliveries.response +// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" +// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" +// @Failure 404 {object} ginresp.apiError "message not found" +// @Failure 500 {object} ginresp.apiError "internal server error" +// +// @Router /api/v2/messages/{mid}/deliveries [GET] +func (h APIHandler) ListMessageDeliveries(pctx ginext.PreContext) ginext.HTTPResponse { + type uri struct { + MessageID models.MessageID `uri:"mid" binding:"entityid"` + } + type response struct { + Deliveries []models.Delivery `json:"deliveries"` + } + + var u uri + ctx, g, errResp := pctx.URI(&u).Start() + if errResp != nil { + return *errResp + } + defer ctx.Cancel() + + return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse { + + if permResp := ctx.CheckPermissionAny(); permResp != nil { + return *permResp + } + + msg, err := h.database.GetMessage(ctx, u.MessageID, false) + if errors.Is(err, sql.ErrNoRows) { + return ginresp.APIError(g, 404, apierr.MESSAGE_NOT_FOUND, "message not found", err) + } + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query message", err) + } + + // User must own the channel and have admin key + if permResp := ctx.CheckPermissionUserAdmin(msg.ChannelOwnerUserID); permResp != nil { + return *permResp + } + + deliveries, err := h.database.ListDeliveriesOfMessage(ctx, msg.MessageID) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query deliveries", err) + } + + return finishSuccess(ginext.JSON(http.StatusOK, response{Deliveries: deliveries})) + }) +} + // DeleteMessage swaggerdoc // // @Summary Delete a single message diff --git a/scnserver/api/router.go b/scnserver/api/router.go index 89bd90f..37161d5 100644 --- a/scnserver/api/router.go +++ b/scnserver/api/router.go @@ -164,6 +164,7 @@ func (r *Router) Init(e *ginext.GinWrapper) error { apiv2.GET("/messages").Handle(r.apiHandler.ListMessages) apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage) apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage) + apiv2.GET("/messages/:mid/deliveries").Handle(r.apiHandler.ListMessageDeliveries) apiv2.GET("/sender-names").Handle(r.apiHandler.ListSenderNames) diff --git a/scnserver/db/impl/primary/deliveries.go b/scnserver/db/impl/primary/deliveries.go index 4dfe673..0d8d366 100644 --- a/scnserver/db/impl/primary/deliveries.go +++ b/scnserver/db/impl/primary/deliveries.go @@ -1,12 +1,13 @@ package primary import ( + "time" + scn "blackforestbytes.com/simplecloudnotifier" "blackforestbytes.com/simplecloudnotifier/db" "blackforestbytes.com/simplecloudnotifier/models" "git.blackforestbytes.com/BlackForestBytes/goext/langext" "git.blackforestbytes.com/BlackForestBytes/goext/sq" - "time" ) func (db *Database) CreateRetryDelivery(ctx db.TxContext, client models.Client, msg models.Message) (models.Delivery, error) { @@ -182,3 +183,12 @@ func (db *Database) DeleteDeliveriesOfChannel(ctx db.TxContext, channelid models return nil } + +func (db *Database) ListDeliveriesOfMessage(ctx db.TxContext, messageID models.MessageID) ([]models.Delivery, error) { + tx, err := ctx.GetOrCreateTransaction(db) + if err != nil { + return nil, err + } + + return sq.QueryAll[models.Delivery](ctx, tx, "SELECT * FROM deliveries WHERE message_id = :mid AND deleted=0 ORDER BY timestamp_created ASC", sq.PP{"mid": messageID}, sq.SModeExtended, sq.Safe) +} diff --git a/scnserver/test/message_test.go b/scnserver/test/message_test.go index 20284cd..efb0f61 100644 --- a/scnserver/test/message_test.go +++ b/scnserver/test/message_test.go @@ -1552,3 +1552,111 @@ func TestListMessagesPaginatedDirectInvalidToken(t *testing.T) { // Test invalid paginated token (float) tt.RequestAuthGetShouldFail(t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$1.5"), 400, apierr.PAGETOKEN_ERROR) } + +func TestListMessageDeliveries(t *testing.T) { + _, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/v2/users", gin.H{ + "agent_model": "DUMMY_PHONE", + "agent_version": "4X", + "client_type": "ANDROID", + "fcm_token": "DUMMY_FCM", + }) + + sendtok := r0["send_key"].(string) + admintok := r0["admin_key"].(string) + + msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{ + "key": sendtok, + "title": "Message_1", + }) + + type delivery struct { + DeliveryID string `json:"delivery_id"` + MessageID string `json:"message_id"` + ReceiverUserID string `json:"receiver_user_id"` + ReceiverClientID string `json:"receiver_client_id"` + Status string `json:"status"` + RetryCount int `json:"retry_count"` + TimestampCreated string `json:"timestamp_created"` + FCMMessageID *string `json:"fcm_message_id"` + } + type deliveryList struct { + Deliveries []delivery `json:"deliveries"` + } + + deliveries := tt.RequestAuthGet[deliveryList](t, admintok, baseUrl, "/api/v2/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"])+"/deliveries") + + tt.AssertTrue(t, "deliveries.len >= 1", len(deliveries.Deliveries) >= 1) + tt.AssertEqual(t, "deliveries[0].message_id", fmt.Sprintf("%v", msg1["scn_msg_id"]), deliveries.Deliveries[0].MessageID) +} + +func TestListMessageDeliveriesNotFound(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + tt.RequestAuthGetShouldFail(t, data.User[0].AdminKey, baseUrl, "/api/v2/messages/"+models.NewMessageID().String()+"/deliveries", 404, apierr.MESSAGE_NOT_FOUND) +} + +func TestListMessageDeliveriesNoAuth(t *testing.T) { + _, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/v2/users", gin.H{ + "agent_model": "DUMMY_PHONE", + "agent_version": "4X", + "client_type": "ANDROID", + "fcm_token": "DUMMY_FCM", + }) + + sendtok := r0["send_key"].(string) + + msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{ + "key": sendtok, + "title": "Message_1", + }) + + tt.RequestGetShouldFail(t, baseUrl, "/api/v2/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"])+"/deliveries", 401, apierr.USER_AUTH_FAILED) +} + +func TestListMessageDeliveriesNonAdminKey(t *testing.T) { + _, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/v2/users", gin.H{ + "agent_model": "DUMMY_PHONE", + "agent_version": "4X", + "client_type": "ANDROID", + "fcm_token": "DUMMY_FCM", + }) + + sendtok := r0["send_key"].(string) + readtok := r0["read_key"].(string) + + msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{ + "key": sendtok, + "title": "Message_1", + }) + + // read key should fail (not admin) + tt.RequestAuthGetShouldFail(t, readtok, baseUrl, "/api/v2/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"])+"/deliveries", 401, apierr.USER_AUTH_FAILED) +} + +func TestListMessageDeliveriesDifferentUserChannel(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + // User 0 sends a message + msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{ + "key": data.User[0].SendKey, + "title": "Message_from_user_0", + }) + + // User 1 tries to access deliveries of User 0's message - should fail + tt.RequestAuthGetShouldFail(t, data.User[1].AdminKey, baseUrl, "/api/v2/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"])+"/deliveries", 401, apierr.USER_AUTH_FAILED) +}