mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: notify users when chats are shared (#26914)
This commit is contained in:
@@ -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
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := ¬ificationstest.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 := ¬ificationstest.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()
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
+78
@@ -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 “<strong>Onboarding kickoff</strong>=
|
||||
” 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>© 2024 Coder. All rights reserved - <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--
|
||||
+30
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user