memos/server/router/api/v1/space_service_notifications.go

184 lines
6.9 KiB
Go

package v1
import (
"context"
"log/slog"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// createSpaceInvitationNotification records the invitation in the invitee's
// inbox. The invitation itself is already persisted, so a failure here only
// loses the notification and is logged instead of failing the request.
func (s *APIV1Service) createSpaceInvitationNotification(ctx context.Context, space *store.Space, inviter *store.User, invitee *store.User) {
if space == nil || inviter == nil || invitee == nil {
return
}
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
SenderID: inviter.ID,
ReceiverID: invitee.ID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_SPACE_INVITATION,
Payload: &storepb.InboxMessage_SpaceInvitation{
SpaceInvitation: &storepb.InboxMessage_SpaceInvitationPayload{SpaceId: space.ID},
},
},
}); err != nil {
slog.Warn("Failed to create space invitation notification",
slog.Any("err", err),
slog.Int64("space_id", int64(space.ID)),
slog.Int64("receiver_id", int64(invitee.ID)))
}
}
// archiveSpaceInvitationNotifications marks the receiver's notifications for
// the space as handled once the invitation has been accepted.
func (s *APIV1Service) archiveSpaceInvitationNotifications(ctx context.Context, receiverID, spaceID int32) {
s.forEachSpaceInvitationNotification(ctx, receiverID, spaceID, func(inbox *store.Inbox) error {
if inbox.Status == store.ARCHIVED {
return nil
}
_, err := s.Store.UpdateInbox(ctx, &store.UpdateInbox{ID: inbox.ID, Status: store.ARCHIVED})
return err
})
}
// deleteSpaceInvitationNotifications removes the receiver's notifications for
// the space once the invitation has been declined or revoked. A declined or
// revoked offer leaves nothing the receiver can act on.
func (s *APIV1Service) deleteSpaceInvitationNotifications(ctx context.Context, receiverID, spaceID int32) {
s.forEachSpaceInvitationNotification(ctx, receiverID, spaceID, func(inbox *store.Inbox) error {
return s.Store.DeleteInbox(ctx, &store.DeleteInbox{ID: inbox.ID})
})
}
func (s *APIV1Service) forEachSpaceInvitationNotification(ctx context.Context, receiverID, spaceID int32, apply func(*store.Inbox) error) {
messageType := storepb.InboxMessage_SPACE_INVITATION
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ReceiverID: &receiverID, MessageType: &messageType})
if err != nil {
slog.Warn("Failed to list space invitation notifications",
slog.Any("err", err),
slog.Int64("space_id", int64(spaceID)),
slog.Int64("receiver_id", int64(receiverID)))
return
}
for _, inbox := range inboxes {
if inbox.Message.GetSpaceInvitation().GetSpaceId() != spaceID {
continue
}
if err := apply(inbox); err != nil {
slog.Warn("Failed to resolve space invitation notification",
slog.Any("err", err),
slog.Int64("inbox_id", int64(inbox.ID)),
slog.Int64("space_id", int64(spaceID)),
slog.Int64("receiver_id", int64(receiverID)))
}
}
}
// spaceInvitationNotificationContext carries the viewer's relationship with
// the spaces referenced by a batch of inbox items. An invitation notification
// is only readable while the viewer holds the pending offer or accepted it.
type spaceInvitationNotificationContext struct {
spacesByID map[int32]*store.Space
pendingBySpaceID map[int32]store.SpaceMemberRole
memberBySpaceID map[int32]store.SpaceMemberRole
}
func (c *spaceInvitationNotificationContext) resolve(spaceID int32) (*store.Space, store.SpaceMemberRole, v1pb.UserNotification_SpaceInvitationPayload_State) {
if c == nil {
return nil, "", v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
}
space := c.spacesByID[spaceID]
if space == nil {
return nil, "", v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
}
if role, ok := c.pendingBySpaceID[spaceID]; ok {
return space, role, v1pb.UserNotification_SpaceInvitationPayload_PENDING
}
if role, ok := c.memberBySpaceID[spaceID]; ok {
return space, role, v1pb.UserNotification_SpaceInvitationPayload_ACCEPTED
}
return nil, "", v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
}
func collectInboxSpaceIDs(inboxes []*store.Inbox) []int32 {
spaceIDs := make([]int32, 0, len(inboxes))
seen := make(map[int32]struct{}, len(inboxes))
for _, inbox := range inboxes {
if inbox == nil || inbox.Message == nil || inbox.Message.Type != storepb.InboxMessage_SPACE_INVITATION {
continue
}
spaceID := inbox.Message.GetSpaceInvitation().GetSpaceId()
if spaceID <= 0 {
continue
}
if _, ok := seen[spaceID]; ok {
continue
}
seen[spaceID] = struct{}{}
spaceIDs = append(spaceIDs, spaceID)
}
return spaceIDs
}
func (s *APIV1Service) loadSpaceInvitationNotificationContext(ctx context.Context, viewer *store.User, inboxes []*store.Inbox) (*spaceInvitationNotificationContext, error) {
result := &spaceInvitationNotificationContext{
spacesByID: map[int32]*store.Space{},
pendingBySpaceID: map[int32]store.SpaceMemberRole{},
memberBySpaceID: map[int32]store.SpaceMemberRole{},
}
spaceIDs := collectInboxSpaceIDs(inboxes)
if viewer == nil || len(spaceIDs) == 0 {
return result, nil
}
spaces, err := s.Store.ListSpaces(ctx, &store.FindSpace{IDList: spaceIDs})
if err != nil {
return nil, err
}
for _, space := range spaces {
result.spacesByID[space.ID] = space
}
invitations, err := s.Store.ListSpaceInvitations(ctx, &store.FindSpaceInvitation{UserID: &viewer.ID, ViewerUserID: &viewer.ID})
if err != nil {
return nil, err
}
for _, invitation := range invitations {
result.pendingBySpaceID[invitation.SpaceID] = invitation.Role
}
members, err := s.Store.ListSpaceMembers(ctx, &store.FindSpaceMember{UserID: &viewer.ID, ViewerUserID: &viewer.ID})
if err != nil {
return nil, err
}
for _, member := range members {
result.memberBySpaceID[member.SpaceID] = member.Role
}
return result, nil
}
// convertSpaceInvitationNotificationPayload returns nil when the viewer no
// longer holds a pending or accepted invitation for the space, so the
// notification fails closed without leaking Space metadata.
func convertSpaceInvitationNotificationPayload(inbox *store.Inbox, viewer *store.User, spaceContext *spaceInvitationNotificationContext) *v1pb.UserNotification_SpaceInvitationPayload {
if inbox == nil || inbox.Message == nil || viewer == nil {
return nil
}
space, role, state := spaceContext.resolve(inbox.Message.GetSpaceInvitation().GetSpaceId())
if space == nil || state == v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED {
return nil
}
convertedRole := convertSpaceMemberRoleFromStore(role)
if convertedRole == v1pb.SpaceMember_ROLE_UNSPECIFIED {
return nil
}
return &v1pb.UserNotification_SpaceInvitationPayload{
SpaceInvitation: buildSpaceInvitationName(space.UID, viewer.Username),
Space: convertSpaceMetadataFromStore(space),
Role: convertedRole,
State: state,
}
}