feat(coderd): add format option to inbox notifications watch endpoint (#17034)

This PR aims to allow clients to use different format for the title and
content of inbox notifications - on the watch endpoint.

This solution will help to have it working and formatted differently on
VSCode, WebUI ...
[Related to this issue](https://github.com/coder/internal/issues/523)
This commit is contained in:
Vincent Vielle
2025-03-21 16:41:13 +01:00
committed by GitHub
parent 0474888eb4
commit 82e37732ea
6 changed files with 128 additions and 17 deletions
+17
View File
@@ -1693,6 +1693,13 @@ const docTemplate = `{
"description": "Filter notifications by read status. Possible values: read, unread, all",
"name": "read_status",
"in": "query"
},
{
"type": "string",
"format": "uuid",
"description": "ID of the last notification from the current page. Notifications returned will be older than the associated one",
"name": "starting_before",
"in": "query"
}
],
"responses": {
@@ -1757,6 +1764,16 @@ const docTemplate = `{
"description": "Filter notifications by read status. Possible values: read, unread, all",
"name": "read_status",
"in": "query"
},
{
"enum": [
"plaintext",
"markdown"
],
"type": "string",
"description": "Define the output format for notifications title and body.",
"name": "format",
"in": "query"
}
],
"responses": {
+14
View File
@@ -1474,6 +1474,13 @@
"description": "Filter notifications by read status. Possible values: read, unread, all",
"name": "read_status",
"in": "query"
},
{
"type": "string",
"format": "uuid",
"description": "ID of the last notification from the current page. Notifications returned will be older than the associated one",
"name": "starting_before",
"in": "query"
}
],
"responses": {
@@ -1532,6 +1539,13 @@
"description": "Filter notifications by read status. Possible values: read, unread, all",
"name": "read_status",
"in": "query"
},
{
"enum": ["plaintext", "markdown"],
"type": "string",
"description": "Define the output format for notifications title and body.",
"name": "format",
"in": "query"
}
],
"responses": {
+26
View File
@@ -17,11 +17,17 @@ import (
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/pubsub"
markdown "github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
)
const (
notificationFormatMarkdown = "markdown"
notificationFormatPlaintext = "plaintext"
)
// convertInboxNotificationResponse works as a util function to transform a database.InboxNotification to codersdk.InboxNotification
func convertInboxNotificationResponse(ctx context.Context, logger slog.Logger, notif database.InboxNotification) codersdk.InboxNotification {
return codersdk.InboxNotification{
@@ -60,6 +66,7 @@ func convertInboxNotificationResponse(ctx context.Context, logger slog.Logger, n
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all"
// @Param format query string false "Define the output format for notifications title and body." enums(plaintext,markdown)
// @Success 200 {object} codersdk.GetInboxNotificationResponse
// @Router /notifications/inbox/watch [get]
func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) {
@@ -73,6 +80,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
targets = p.UUIDs(vals, []uuid.UUID{}, "targets")
templates = p.UUIDs(vals, []uuid.UUID{}, "templates")
readStatus = p.String(vals, "all", "read_status")
format = p.String(vals, notificationFormatMarkdown, "format")
)
p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
@@ -176,6 +184,23 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
api.Logger.Error(ctx, "failed to count unread inbox notifications", slog.Error(err))
return
}
// By default, notifications are stored as markdown
// We can change the format based on parameter if required
if format == notificationFormatPlaintext {
notif.Title, err = markdown.PlaintextFromMarkdown(notif.Title)
if err != nil {
api.Logger.Error(ctx, "failed to convert notification title to plain text", slog.Error(err))
return
}
notif.Content, err = markdown.PlaintextFromMarkdown(notif.Content)
if err != nil {
api.Logger.Error(ctx, "failed to convert notification content to plain text", slog.Error(err))
return
}
}
if err := encoder.Encode(codersdk.GetInboxNotificationResponse{
Notification: notif,
UnreadCount: int(unreadCount),
@@ -196,6 +221,7 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request)
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all"
// @Param starting_before query string false "ID of the last notification from the current page. Notifications returned will be older than the associated one" format(uuid)
// @Success 200 {object} codersdk.ListInboxNotificationsResponse
// @Router /notifications/inbox [get]
func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) {
+56
View File
@@ -137,6 +137,62 @@ func TestInboxNotification_Watch(t *testing.T) {
require.Equal(t, memberClient.ID, notif.Notification.UserID)
})
t.Run("OK - change format", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
firstClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
Pubsub: ps,
Database: db,
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberClient := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
u, err := member.URL.Parse("/api/v2/notifications/inbox/watch?format=plaintext")
require.NoError(t, err)
// nolint:bodyclose
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
"Coder-Session-Token": []string{member.SessionToken()},
},
})
if err != nil {
if resp.StatusCode != http.StatusSwitchingProtocols {
err = codersdk.ReadBodyAsError(resp)
}
require.NoError(t, err)
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")
inboxHandler := dispatch.NewInboxHandler(logger, db, ps)
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
UserID: memberClient.ID.String(),
NotificationTemplateID: notifications.TemplateWorkspaceOutOfMemory.String(),
}, "# Notification Title", "This is the __content__.", nil)
require.NoError(t, err)
_, err = dispatchFunc(ctx, uuid.New())
require.NoError(t, err)
_, message, err := wsConn.Read(ctx)
require.NoError(t, err)
var notif codersdk.GetInboxNotificationResponse
err = json.Unmarshal(message, &notif)
require.NoError(t, err)
require.Equal(t, 1, notif.UnreadCount)
require.Equal(t, memberClient.ID, notif.Notification.UserID)
require.Equal(t, "Notification Title", notif.Notification.Title)
require.Equal(t, "This is the content.", notif.Notification.Content)
})
t.Run("OK - filters on templates", func(t *testing.T) {
t.Parallel()
+1 -12
View File
@@ -16,7 +16,6 @@ import (
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/notifications/types"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
markdown "github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/codersdk"
)
@@ -36,17 +35,7 @@ func NewInboxHandler(log slog.Logger, store InboxStore, ps pubsub.Pubsub) *Inbox
}
func (s *InboxHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string, _ template.FuncMap) (DeliveryFunc, error) {
subject, err := markdown.PlaintextFromMarkdown(titleTmpl)
if err != nil {
return nil, xerrors.Errorf("render subject: %w", err)
}
htmlBody, err := markdown.PlaintextFromMarkdown(bodyTmpl)
if err != nil {
return nil, xerrors.Errorf("render html body: %w", err)
}
return s.dispatch(payload, subject, htmlBody), nil
return s.dispatch(payload, titleTmpl, bodyTmpl), nil
}
func (s *InboxHandler) dispatch(payload types.MessagePayload, title, body string) DeliveryFunc {
+14 -5
View File
@@ -61,11 +61,12 @@ curl -X GET http://coder-server:8080/api/v2/notifications/inbox \
### Parameters
| Name | In | Type | Required | Description |
|---------------|-------|--------|----------|-------------------------------------------------------------------------|
| `targets` | query | string | false | Comma-separated list of target IDs to filter notifications |
| `templates` | query | string | false | Comma-separated list of template IDs to filter notifications |
| `read_status` | query | string | false | Filter notifications by read status. Possible values: read, unread, all |
| Name | In | Type | Required | Description |
|-------------------|-------|--------------|----------|-----------------------------------------------------------------------------------------------------------------|
| `targets` | query | string | false | Comma-separated list of target IDs to filter notifications |
| `templates` | query | string | false | Comma-separated list of template IDs to filter notifications |
| `read_status` | query | string | false | Filter notifications by read status. Possible values: read, unread, all |
| `starting_before` | query | string(uuid) | false | ID of the last notification from the current page. Notifications returned will be older than the associated one |
### Example responses
@@ -146,6 +147,14 @@ curl -X GET http://coder-server:8080/api/v2/notifications/inbox/watch \
| `targets` | query | string | false | Comma-separated list of target IDs to filter notifications |
| `templates` | query | string | false | Comma-separated list of template IDs to filter notifications |
| `read_status` | query | string | false | Filter notifications by read status. Possible values: read, unread, all |
| `format` | query | string | false | Define the output format for notifications title and body. |
#### Enumerated Values
| Parameter | Value |
|-----------|-------------|
| `format` | `plaintext` |
| `format` | `markdown` |
### Example responses