feat: notify users when chats are shared (#26914)

This commit is contained in:
Danielle Maywood
2026-07-06 13:15:58 +01:00
committed by GitHub
parent b21e0717d5
commit 7c78698a6d
11 changed files with 311 additions and 2 deletions
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = 'b789bd75-d7c6-4cab-9757-1147ab184903';
@@ -0,0 +1,27 @@
INSERT INTO notification_templates (
id,
name,
title_template,
body_template,
actions,
"group",
method,
kind,
enabled_by_default
)
VALUES (
'b789bd75-d7c6-4cab-9757-1147ab184903',
'Chat Shared',
E'{{.Labels.initiator}} shared a chat with you',
E'{{.Labels.initiator}} shared the chat "**{{.Labels.chat_title}}**" with you.',
'[
{
"label": "View chat",
"url": "{{base_url}}/agents/{{.Labels.chat_id}}"
}
]'::jsonb,
'Chat Events',
NULL,
'system'::notification_template_kind,
true
);
+68
View File
@@ -3,7 +3,10 @@ package coderd
import (
"context"
"database/sql"
"errors"
"maps"
"net/http"
"slices"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -15,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/acl"
"github.com/coder/coder/v2/coderd/rbac/policy"
@@ -145,6 +149,7 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) {
return
}
var oldChat database.Chat
err := api.Database.InTx(func(tx database.Store) error {
current, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
if err != nil {
@@ -156,6 +161,8 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) {
if current.GroupACL == nil {
current.GroupACL = database.ChatACL{}
}
oldChat = current
oldChat.UserACL = maps.Clone(current.UserACL)
for id, role := range req.UserRoles {
if role == codersdk.ChatRoleDeleted {
@@ -199,9 +206,70 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) {
return
}
initiator, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if err != nil {
api.Logger.Warn(ctx, "failed to load chat share initiator", slog.Error(err), slog.F("chat_id", chat.ID))
} else {
newChat := aReq.New
go func() {
if count, err := api.notifyChatShared(oldChat, newChat, initiator); err != nil {
api.Logger.Warn(api.ctx, "failed to enqueue one or more chat shared notifications", slog.Error(err), slog.F("chat_id", newChat.ID), slog.F("attempted_recipients", count))
}
}()
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) notifyChatShared(oldChat database.Chat, newChat database.Chat, initiator database.User) (int, error) {
oldReaders := api.directChatReaders(oldChat)
newReaders := api.directChatReaders(newChat)
added, _ := slice.SymmetricDifference(oldReaders, newReaders)
recipientIDs := make([]uuid.UUID, 0, len(added))
for _, userID := range added {
if userID == initiator.ID {
continue
}
recipientIDs = append(recipientIDs, userID)
}
if len(recipientIDs) == 0 {
return 0, nil
}
labels := map[string]string{
"chat_id": newChat.ID.String(),
"chat_title": newChat.Title,
"initiator": initiator.Username,
}
//nolint:gocritic // Notifier actor is required to enqueue notifications.
notifierCtx := dbauthz.AsNotifier(api.ctx)
var errs []error
for _, userID := range recipientIDs {
if _, err := api.NotificationsEnqueuer.Enqueue(notifierCtx, userID, notifications.TemplateChatShared, labels, initiator.ID.String(), newChat.ID); err != nil {
errs = append(errs, xerrors.Errorf("enqueue chat shared notification: %w", err))
}
}
return len(recipientIDs), errors.Join(errs...)
}
func (api *API) directChatReaders(chat database.Chat) []uuid.UUID {
readers := []uuid.UUID{chat.OwnerID}
for rawUserID, entry := range chat.UserACL {
if !slices.Contains(entry.Permissions, policy.ActionRead) {
continue
}
userID, err := uuid.Parse(rawUserID)
if err != nil {
api.Logger.Warn(api.ctx, "skip chat ACL entry with invalid user UUID", slog.F("chat_id", chat.ID), slog.F("user_id", rawUserID), slog.Error(err))
continue
}
readers = append(readers, userID)
}
return slice.Unique(readers)
}
func (api *API) chatACLUsers(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatUser, bool) {
userIDs := make([]uuid.UUID, 0, len(entries))
for userID := range entries {
+87
View File
@@ -16,6 +16,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -28,8 +30,10 @@ func TestChatACLSharingLifecycle(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
notifyEnq := &notificationstest.FakeEnqueuer{}
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
opts.NotificationsEnqueuer = notifyEnq
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
@@ -68,6 +72,36 @@ func TestChatACLSharingLifecycle(t *testing.T) {
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
// Only the direct user-ACL grant is notified. The group member gains
// access but is not notified, because group grants are not expanded.
var sent []*notificationstest.FakeNotification
testutil.Eventually(ctx, t, func(context.Context) bool {
sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared))
return len(sent) == 1
}, testutil.IntervalFast)
require.Equal(t, sharedUser.ID, sent[0].UserID)
require.Equal(t, firstUser.UserID.String(), sent[0].CreatedBy)
require.Equal(t, map[string]string{
"chat_id": chat.ID.String(),
"chat_title": chat.Title,
"initiator": coderdtest.FirstUserParams.Username,
}, sent[0].Labels)
require.Equal(t, []uuid.UUID{chat.ID}, sent[0].Targets)
for _, notification := range sent {
require.NotEqual(t, groupMember.ID, notification.UserID)
}
notifyEnq.Clear()
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
sharedUser.ID.String(): codersdk.ChatRoleRead,
},
GroupRoles: map[string]codersdk.ChatRole{
sharedGroup.ID.String(): codersdk.ChatRoleRead,
},
})
require.NoError(t, err)
require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)))
acl, err := client.GetChatACL(ctx, chat.ID)
require.NoError(t, err)
@@ -156,6 +190,7 @@ func TestChatACLSharingLifecycle(t *testing.T) {
},
})
require.NoError(t, err)
require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)))
_, err = sharedClientExp.GetChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
_, err = groupMemberClientExp.GetChat(ctx, chat.ID)
@@ -168,6 +203,7 @@ func TestChatACLSharingLifecycle(t *testing.T) {
},
})
require.NoError(t, err)
require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)))
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
@@ -178,6 +214,57 @@ func TestChatACLSharingLifecycle(t *testing.T) {
requireSDKError(t, err, http.StatusNotFound)
}
func TestChatACLSharingNotifiesDirectReadersOnly(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
notifyEnq := &notificationstest.FakeEnqueuer{}
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.NotificationsEnqueuer = notifyEnq
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// A non-owner org admin can share another user's chat via ActionShare.
adminClient, admin := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
adminExp := codersdk.NewExperimentalClient(adminClient)
_, groupMember := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
_, directUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
// The group contains both the sharing initiator and another member.
group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: admin.ID})
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID})
chat := createChatForSharing(ctx, t, client, firstUser.OrganizationID, "admin shared chat")
// Share with the group (which grants access to groupMember) and with
// directUser via the user ACL. Only directUser should be notified.
err := adminExp.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
directUser.ID.String(): codersdk.ChatRoleRead,
},
GroupRoles: map[string]codersdk.ChatRole{
group.ID.String(): codersdk.ChatRoleRead,
},
})
require.NoError(t, err)
var sent []*notificationstest.FakeNotification
testutil.Eventually(ctx, t, func(context.Context) bool {
sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared))
return len(sent) == 1
}, testutil.IntervalFast)
// Only the direct user-ACL grant is notified. The group member, initiator
// (admin), and owner (firstUser) are never notified.
require.Equal(t, directUser.ID, sent[0].UserID)
for _, notification := range sent {
require.NotEqual(t, groupMember.ID, notification.UserID)
require.NotEqual(t, admin.ID, notification.UserID)
require.NotEqual(t, firstUser.UserID, notification.UserID)
}
}
func TestChatACLSubChatInheritance(t *testing.T) {
t.Parallel()
+1
View File
@@ -58,6 +58,7 @@ var fallbackIcons = map[uuid.UUID]string{
// chat related notifications
notifications.TemplateChatAutoArchiveDigest: codersdk.InboxNotificationFallbackIconOther,
notifications.TemplateChatShared: codersdk.InboxNotificationFallbackIconOther,
}
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
@@ -23,6 +23,7 @@ func TestInboxNotifications_ensureNotificationIcon(t *testing.T) {
{"WorkspaceCreated", "", notifications.TemplateWorkspaceCreated, codersdk.InboxNotificationFallbackIconWorkspace},
{"UserAccountCreated", "", notifications.TemplateUserAccountCreated, codersdk.InboxNotificationFallbackIconAccount},
{"TemplateDeleted", "", notifications.TemplateTemplateDeleted, codersdk.InboxNotificationFallbackIconTemplate},
{"ChatShared", "", notifications.TemplateChatShared, codersdk.InboxNotificationFallbackIconOther},
{"TestNotification", "", notifications.TemplateTestNotification, codersdk.InboxNotificationFallbackIconOther},
{"TestExistingIcon", "https://cdn.coder.com/icon_notif.png", notifications.TemplateTemplateDeleted, "https://cdn.coder.com/icon_notif.png"},
{"UnknownTemplate", "", uuid.New(), codersdk.InboxNotificationFallbackIconOther},
+1
View File
@@ -67,4 +67,5 @@ var (
// Chat-related events.
var (
TemplateChatAutoArchiveDigest = uuid.MustParse("764031be-4863-4220-867b-6ce1a1b7a5f5")
TemplateChatShared = uuid.MustParse("b789bd75-d7c6-4cab-9757-1147ab184903")
)
@@ -1363,6 +1363,21 @@ func TestNotificationTemplates_Golden(t *testing.T) {
Data: map[string]any{},
},
},
{
name: "TemplateChatShared",
id: notifications.TemplateChatShared,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{
"chat_id": "00000000-0000-0000-0000-000000000001",
"chat_title": "Onboarding kickoff",
"initiator": "alice",
},
Data: map[string]any{},
},
},
{
// Default branch: multiple visible chats, retention enabled,
// no overflow. Body phrasing is number-neutral so this also
@@ -0,0 +1,78 @@
From: system@coder.com
To: bobby@coder.com
Subject: alice shared a chat with you
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
alice shared the chat "Onboarding kickoff" with you.
View chat: http://test.com/agents/00000000-0000-0000-0000-000000000001
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>alice shared a chat with you</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
alice shared a chat with you
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>alice shared the chat &ldquo;<strong>Onboarding kickoff</strong>=
&rdquo; with you.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents/00000000-0000-0000-0000-000000000=
001" style=3D"display: inline-block; padding: 13px 24px; background-color: =
#020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin:=
0 4px;">
View chat
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3Db78=
9bd75-d7c6-4cab-9757-1147ab184903" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -0,0 +1,30 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Chat Shared",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View chat",
"url": "http://test.com/agents/00000000-0000-0000-0000-000000000000"
}
],
"labels": {
"chat_id": "00000000-0000-0000-0000-000000000000",
"chat_title": "Onboarding kickoff",
"initiator": "alice"
},
"data": {},
"targets": null
},
"title": "alice shared a chat with you",
"title_markdown": "alice shared a chat with you",
"body": "alice shared the chat \"Onboarding kickoff\" with you.",
"body_markdown": "alice shared the chat \"**Onboarding kickoff**\" with you."
}