feat(spaces): deliver invitations to the inbox (#6266)
This commit is contained in:
parent
e963d326ab
commit
230e3a1d11
25 changed files with 1498 additions and 240 deletions
|
|
@ -172,6 +172,8 @@ MCP memo operations reuse the same memo policy; Space management is not exposed
|
|||
|
||||
The active Space scopes collaborative resource browsing and creation, including Home, Explore, and attachment lists. Archived, Inbox, and user profiles remain user-global and their routes do not inherit the active Space. Global Settings provides a Spaces section for viewing received invitations and managing joined Spaces, metadata, members, roles, and pending invitations; it is a management surface rather than another Space switcher.
|
||||
|
||||
Creating an invitation also delivers a `SPACE_INVITATION` inbox notification to the invitee, with the same email dispatch as memo notifications when email is enabled. The notification carries the read-only Space summary and offered role, and the invitee can accept or decline from the Inbox. Accepting archives the notification and keeps it as history while the membership is active; declining or revoking deletes it. At read time the notification fails closed: it is omitted unless the receiver still holds the pending invitation or is an active member of the Space, so a deleted Space or a revoked offer leaks no metadata.
|
||||
|
||||
### Security invariants
|
||||
|
||||
Before `SPACE` can be stored, one shared, memo-local, fail-closed policy must cover point reads, lists and counts, files, reactions, relations, notifications, email, webhooks, shares, search, statistics, public feeds, and MCP. Child resources resolve the memo they directly belong to. Application `ADMIN` receives no implicit bypass.
|
||||
|
|
@ -213,4 +215,4 @@ Files for `PRIVATE`, `PROTECTED`, and `SPACE` memos use `private, no-store`; pub
|
|||
|
||||
## Deferred design
|
||||
|
||||
Email and external-user invitations, invitation expiration, inviter attribution, invitation history, open enrollment, account erasure beyond the membership guard, notification delivery and retention policy, application-admin moderation and recovery, audit history, soft deletion, restoration, retryable external-object cleanup, delivery-time cancellation of queued email/webhooks, broader concurrent-mutation hardening, and asynchronous deletion of very large Spaces remain deferred. Later work must preserve invitee consent, independent memo authorization, non-propagating relations, and explicit Space aggregate deletion unless this design is revisited.
|
||||
Email and external-user invitations, invitation expiration, inviter attribution, invitation history, open enrollment, account erasure beyond the membership guard, notification retention policy and per-user notification preferences, application-admin moderation and recovery, audit history, soft deletion, restoration, retryable external-object cleanup, delivery-time cancellation of queued email/webhooks, broader concurrent-mutation hardening, and asynchronous deletion of very large Spaces remain deferred. Later work must preserve invitee consent, independent memo authorization, non-propagating relations, and explicit Space aggregate deletion unless this design is revisited.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ syntax = "proto3";
|
|||
package memos.api.v1;
|
||||
|
||||
import "api/v1/common.proto";
|
||||
import "api/v1/space_service.proto";
|
||||
import "google/api/annotations.proto";
|
||||
import "google/api/client.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
|
|
@ -832,6 +833,7 @@ message UserNotification {
|
|||
oneof payload {
|
||||
MemoCommentPayload memo_comment = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
MemoMentionPayload memo_mention = 7 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
SpaceInvitationPayload space_invitation = 9 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
}
|
||||
|
||||
message MemoCommentPayload {
|
||||
|
|
@ -866,6 +868,29 @@ message UserNotification {
|
|||
string related_memo_snippet = 4;
|
||||
}
|
||||
|
||||
message SpaceInvitationPayload {
|
||||
// The invitation resource.
|
||||
// Format: spaces/{space}/invitations/{username}
|
||||
string space_invitation = 1 [(google.api.resource_reference) = {type: "memos.api.v1/SpaceInvitation"}];
|
||||
|
||||
// The read-only Space summary carried by the invitation.
|
||||
Space space = 2;
|
||||
|
||||
// The role the invitation offers.
|
||||
SpaceMember.Role role = 3;
|
||||
|
||||
// The current state of the invitation for the receiver.
|
||||
State state = 4;
|
||||
|
||||
enum State {
|
||||
STATE_UNSPECIFIED = 0;
|
||||
// The receiver has not yet accepted or declined the invitation.
|
||||
PENDING = 1;
|
||||
// The receiver accepted the invitation and is an active member.
|
||||
ACCEPTED = 2;
|
||||
}
|
||||
}
|
||||
|
||||
enum Status {
|
||||
STATUS_UNSPECIFIED = 0;
|
||||
UNREAD = 1;
|
||||
|
|
@ -876,6 +901,7 @@ message UserNotification {
|
|||
TYPE_UNSPECIFIED = 0;
|
||||
MEMO_COMMENT = 1;
|
||||
MEMO_MENTION = 2;
|
||||
SPACE_INVITATION = 3;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ const (
|
|||
UserNotification_TYPE_UNSPECIFIED UserNotification_Type = 0
|
||||
UserNotification_MEMO_COMMENT UserNotification_Type = 1
|
||||
UserNotification_MEMO_MENTION UserNotification_Type = 2
|
||||
UserNotification_SPACE_INVITATION UserNotification_Type = 3
|
||||
)
|
||||
|
||||
// Enum value maps for UserNotification_Type.
|
||||
|
|
@ -197,11 +198,13 @@ var (
|
|||
0: "TYPE_UNSPECIFIED",
|
||||
1: "MEMO_COMMENT",
|
||||
2: "MEMO_MENTION",
|
||||
3: "SPACE_INVITATION",
|
||||
}
|
||||
UserNotification_Type_value = map[string]int32{
|
||||
"TYPE_UNSPECIFIED": 0,
|
||||
"MEMO_COMMENT": 1,
|
||||
"MEMO_MENTION": 2,
|
||||
"SPACE_INVITATION": 3,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -232,6 +235,57 @@ func (UserNotification_Type) EnumDescriptor() ([]byte, []int) {
|
|||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{38, 1}
|
||||
}
|
||||
|
||||
type UserNotification_SpaceInvitationPayload_State int32
|
||||
|
||||
const (
|
||||
UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED UserNotification_SpaceInvitationPayload_State = 0
|
||||
// The receiver has not yet accepted or declined the invitation.
|
||||
UserNotification_SpaceInvitationPayload_PENDING UserNotification_SpaceInvitationPayload_State = 1
|
||||
// The receiver accepted the invitation and is an active member.
|
||||
UserNotification_SpaceInvitationPayload_ACCEPTED UserNotification_SpaceInvitationPayload_State = 2
|
||||
)
|
||||
|
||||
// Enum value maps for UserNotification_SpaceInvitationPayload_State.
|
||||
var (
|
||||
UserNotification_SpaceInvitationPayload_State_name = map[int32]string{
|
||||
0: "STATE_UNSPECIFIED",
|
||||
1: "PENDING",
|
||||
2: "ACCEPTED",
|
||||
}
|
||||
UserNotification_SpaceInvitationPayload_State_value = map[string]int32{
|
||||
"STATE_UNSPECIFIED": 0,
|
||||
"PENDING": 1,
|
||||
"ACCEPTED": 2,
|
||||
}
|
||||
)
|
||||
|
||||
func (x UserNotification_SpaceInvitationPayload_State) Enum() *UserNotification_SpaceInvitationPayload_State {
|
||||
p := new(UserNotification_SpaceInvitationPayload_State)
|
||||
*p = x
|
||||
return p
|
||||
}
|
||||
|
||||
func (x UserNotification_SpaceInvitationPayload_State) String() string {
|
||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||
}
|
||||
|
||||
func (UserNotification_SpaceInvitationPayload_State) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_api_v1_user_service_proto_enumTypes[4].Descriptor()
|
||||
}
|
||||
|
||||
func (UserNotification_SpaceInvitationPayload_State) Type() protoreflect.EnumType {
|
||||
return &file_api_v1_user_service_proto_enumTypes[4]
|
||||
}
|
||||
|
||||
func (x UserNotification_SpaceInvitationPayload_State) Number() protoreflect.EnumNumber {
|
||||
return protoreflect.EnumNumber(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UserNotification_SpaceInvitationPayload_State.Descriptor instead.
|
||||
func (UserNotification_SpaceInvitationPayload_State) EnumDescriptor() ([]byte, []int) {
|
||||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{38, 2, 0}
|
||||
}
|
||||
|
||||
type User struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The resource name of the user.
|
||||
|
|
@ -2591,6 +2645,7 @@ type UserNotification struct {
|
|||
//
|
||||
// *UserNotification_MemoComment
|
||||
// *UserNotification_MemoMention
|
||||
// *UserNotification_SpaceInvitation
|
||||
Payload isUserNotification_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
|
@ -2693,6 +2748,15 @@ func (x *UserNotification) GetMemoMention() *UserNotification_MemoMentionPayload
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *UserNotification) GetSpaceInvitation() *UserNotification_SpaceInvitationPayload {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*UserNotification_SpaceInvitation); ok {
|
||||
return x.SpaceInvitation
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isUserNotification_Payload interface {
|
||||
isUserNotification_Payload()
|
||||
}
|
||||
|
|
@ -2705,10 +2769,16 @@ type UserNotification_MemoMention struct {
|
|||
MemoMention *UserNotification_MemoMentionPayload `protobuf:"bytes,7,opt,name=memo_mention,json=memoMention,proto3,oneof"`
|
||||
}
|
||||
|
||||
type UserNotification_SpaceInvitation struct {
|
||||
SpaceInvitation *UserNotification_SpaceInvitationPayload `protobuf:"bytes,9,opt,name=space_invitation,json=spaceInvitation,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*UserNotification_MemoComment) isUserNotification_Payload() {}
|
||||
|
||||
func (*UserNotification_MemoMention) isUserNotification_Payload() {}
|
||||
|
||||
func (*UserNotification_SpaceInvitation) isUserNotification_Payload() {}
|
||||
|
||||
type ListUserNotificationsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The parent user resource.
|
||||
|
|
@ -3369,11 +3439,84 @@ func (x *UserNotification_MemoMentionPayload) GetRelatedMemoSnippet() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
type UserNotification_SpaceInvitationPayload struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The invitation resource.
|
||||
// Format: spaces/{space}/invitations/{username}
|
||||
SpaceInvitation string `protobuf:"bytes,1,opt,name=space_invitation,json=spaceInvitation,proto3" json:"space_invitation,omitempty"`
|
||||
// The read-only Space summary carried by the invitation.
|
||||
Space *Space `protobuf:"bytes,2,opt,name=space,proto3" json:"space,omitempty"`
|
||||
// The role the invitation offers.
|
||||
Role SpaceMember_Role `protobuf:"varint,3,opt,name=role,proto3,enum=memos.api.v1.SpaceMember_Role" json:"role,omitempty"`
|
||||
// The current state of the invitation for the receiver.
|
||||
State UserNotification_SpaceInvitationPayload_State `protobuf:"varint,4,opt,name=state,proto3,enum=memos.api.v1.UserNotification_SpaceInvitationPayload_State" json:"state,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UserNotification_SpaceInvitationPayload) Reset() {
|
||||
*x = UserNotification_SpaceInvitationPayload{}
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[52]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *UserNotification_SpaceInvitationPayload) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UserNotification_SpaceInvitationPayload) ProtoMessage() {}
|
||||
|
||||
func (x *UserNotification_SpaceInvitationPayload) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[52]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UserNotification_SpaceInvitationPayload.ProtoReflect.Descriptor instead.
|
||||
func (*UserNotification_SpaceInvitationPayload) Descriptor() ([]byte, []int) {
|
||||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{38, 2}
|
||||
}
|
||||
|
||||
func (x *UserNotification_SpaceInvitationPayload) GetSpaceInvitation() string {
|
||||
if x != nil {
|
||||
return x.SpaceInvitation
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UserNotification_SpaceInvitationPayload) GetSpace() *Space {
|
||||
if x != nil {
|
||||
return x.Space
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *UserNotification_SpaceInvitationPayload) GetRole() SpaceMember_Role {
|
||||
if x != nil {
|
||||
return x.Role
|
||||
}
|
||||
return SpaceMember_ROLE_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (x *UserNotification_SpaceInvitationPayload) GetState() UserNotification_SpaceInvitationPayload_State {
|
||||
if x != nil {
|
||||
return x.State
|
||||
}
|
||||
return UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
|
||||
}
|
||||
|
||||
var File_api_v1_user_service_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x19api/v1/user_service.proto\x12\fmemos.api.v1\x1a\x13api/v1/common.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17google/type/color.proto\"\xc1\x04\n" +
|
||||
"\x19api/v1/user_service.proto\x12\fmemos.api.v1\x1a\x13api/v1/common.proto\x1a\x1aapi/v1/space_service.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17google/type/color.proto\"\xc1\x04\n" +
|
||||
"\x04User\x12\x17\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x120\n" +
|
||||
"\x04role\x18\x02 \x01(\x0e2\x17.memos.api.v1.User.RoleB\x03\xe0A\x02R\x04role\x12\x1f\n" +
|
||||
|
|
@ -3588,7 +3731,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
|||
"\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" +
|
||||
"\x18memos.api.v1/UserWebhookR\x04name\"L\n" +
|
||||
"#GetUserWebhookSigningSecretResponse\x12%\n" +
|
||||
"\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\"\xda\b\n" +
|
||||
"\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\"\xaf\f\n" +
|
||||
"\x10UserNotification\x12\x1a\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x06\xe0A\x03\xe0A\bR\x04name\x121\n" +
|
||||
"\x06sender\x18\x02 \x01(\tB\x19\xe0A\x03\xfaA\x13\n" +
|
||||
|
|
@ -3600,7 +3743,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
|||
"createTime\x12<\n" +
|
||||
"\x04type\x18\x05 \x01(\x0e2#.memos.api.v1.UserNotification.TypeB\x03\xe0A\x03R\x04type\x12[\n" +
|
||||
"\fmemo_comment\x18\x06 \x01(\v21.memos.api.v1.UserNotification.MemoCommentPayloadB\x03\xe0A\x03H\x00R\vmemoComment\x12[\n" +
|
||||
"\fmemo_mention\x18\a \x01(\v21.memos.api.v1.UserNotification.MemoMentionPayloadB\x03\xe0A\x03H\x00R\vmemoMention\x1a\xa0\x01\n" +
|
||||
"\fmemo_mention\x18\a \x01(\v21.memos.api.v1.UserNotification.MemoMentionPayloadB\x03\xe0A\x03H\x00R\vmemoMention\x12g\n" +
|
||||
"\x10space_invitation\x18\t \x01(\v25.memos.api.v1.UserNotification.SpaceInvitationPayloadB\x03\xe0A\x03H\x00R\x0fspaceInvitation\x1a\xa0\x01\n" +
|
||||
"\x12MemoCommentPayload\x12\x12\n" +
|
||||
"\x04memo\x18\x01 \x01(\tR\x04memo\x12!\n" +
|
||||
"\frelated_memo\x18\x02 \x01(\tR\vrelatedMemo\x12!\n" +
|
||||
|
|
@ -3610,16 +3754,27 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
|||
"\x04memo\x18\x01 \x01(\tR\x04memo\x12!\n" +
|
||||
"\frelated_memo\x18\x02 \x01(\tR\vrelatedMemo\x12!\n" +
|
||||
"\fmemo_snippet\x18\x03 \x01(\tR\vmemoSnippet\x120\n" +
|
||||
"\x14related_memo_snippet\x18\x04 \x01(\tR\x12relatedMemoSnippet\":\n" +
|
||||
"\x14related_memo_snippet\x18\x04 \x01(\tR\x12relatedMemoSnippet\x1a\xd3\x02\n" +
|
||||
"\x16SpaceInvitationPayload\x12L\n" +
|
||||
"\x10space_invitation\x18\x01 \x01(\tB!\xfaA\x1e\n" +
|
||||
"\x1cmemos.api.v1/SpaceInvitationR\x0fspaceInvitation\x12)\n" +
|
||||
"\x05space\x18\x02 \x01(\v2\x13.memos.api.v1.SpaceR\x05space\x122\n" +
|
||||
"\x04role\x18\x03 \x01(\x0e2\x1e.memos.api.v1.SpaceMember.RoleR\x04role\x12Q\n" +
|
||||
"\x05state\x18\x04 \x01(\x0e2;.memos.api.v1.UserNotification.SpaceInvitationPayload.StateR\x05state\"9\n" +
|
||||
"\x05State\x12\x15\n" +
|
||||
"\x11STATE_UNSPECIFIED\x10\x00\x12\v\n" +
|
||||
"\aPENDING\x10\x01\x12\f\n" +
|
||||
"\bACCEPTED\x10\x02\":\n" +
|
||||
"\x06Status\x12\x16\n" +
|
||||
"\x12STATUS_UNSPECIFIED\x10\x00\x12\n" +
|
||||
"\n" +
|
||||
"\x06UNREAD\x10\x01\x12\f\n" +
|
||||
"\bARCHIVED\x10\x02\"@\n" +
|
||||
"\bARCHIVED\x10\x02\"V\n" +
|
||||
"\x04Type\x12\x14\n" +
|
||||
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
|
||||
"\fMEMO_COMMENT\x10\x01\x12\x10\n" +
|
||||
"\fMEMO_MENTION\x10\x02:p\xeaAm\n" +
|
||||
"\fMEMO_MENTION\x10\x02\x12\x14\n" +
|
||||
"\x10SPACE_INVITATION\x10\x03:p\xeaAm\n" +
|
||||
"\x1dmemos.api.v1/UserNotification\x12)users/{user}/notifications/{notification}\x1a\x04name*\rnotifications2\fnotificationB\t\n" +
|
||||
"\apayload\"\xb4\x01\n" +
|
||||
"\x1cListUserNotificationsRequest\x121\n" +
|
||||
|
|
@ -3683,176 +3838,184 @@ func file_api_v1_user_service_proto_rawDescGZIP() []byte {
|
|||
return file_api_v1_user_service_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_api_v1_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
|
||||
var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 52)
|
||||
var file_api_v1_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
|
||||
var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 53)
|
||||
var file_api_v1_user_service_proto_goTypes = []any{
|
||||
(User_Role)(0), // 0: memos.api.v1.User.Role
|
||||
(UserSetting_Key)(0), // 1: memos.api.v1.UserSetting.Key
|
||||
(UserNotification_Status)(0), // 2: memos.api.v1.UserNotification.Status
|
||||
(UserNotification_Type)(0), // 3: memos.api.v1.UserNotification.Type
|
||||
(*User)(nil), // 4: memos.api.v1.User
|
||||
(*ListUsersRequest)(nil), // 5: memos.api.v1.ListUsersRequest
|
||||
(*ListUsersResponse)(nil), // 6: memos.api.v1.ListUsersResponse
|
||||
(*BatchGetUsersRequest)(nil), // 7: memos.api.v1.BatchGetUsersRequest
|
||||
(*BatchGetUsersResponse)(nil), // 8: memos.api.v1.BatchGetUsersResponse
|
||||
(*GetUserRequest)(nil), // 9: memos.api.v1.GetUserRequest
|
||||
(*CreateUserRequest)(nil), // 10: memos.api.v1.CreateUserRequest
|
||||
(*UpdateUserRequest)(nil), // 11: memos.api.v1.UpdateUserRequest
|
||||
(*DeleteUserRequest)(nil), // 12: memos.api.v1.DeleteUserRequest
|
||||
(*UserStats)(nil), // 13: memos.api.v1.UserStats
|
||||
(*GetUserStatsRequest)(nil), // 14: memos.api.v1.GetUserStatsRequest
|
||||
(*ListAllUserStatsRequest)(nil), // 15: memos.api.v1.ListAllUserStatsRequest
|
||||
(*ListAllUserStatsResponse)(nil), // 16: memos.api.v1.ListAllUserStatsResponse
|
||||
(*UserSetting)(nil), // 17: memos.api.v1.UserSetting
|
||||
(*GetUserSettingRequest)(nil), // 18: memos.api.v1.GetUserSettingRequest
|
||||
(*UpdateUserSettingRequest)(nil), // 19: memos.api.v1.UpdateUserSettingRequest
|
||||
(*ListUserSettingsRequest)(nil), // 20: memos.api.v1.ListUserSettingsRequest
|
||||
(*ListUserSettingsResponse)(nil), // 21: memos.api.v1.ListUserSettingsResponse
|
||||
(*LinkedIdentity)(nil), // 22: memos.api.v1.LinkedIdentity
|
||||
(*ListLinkedIdentitiesRequest)(nil), // 23: memos.api.v1.ListLinkedIdentitiesRequest
|
||||
(*ListLinkedIdentitiesResponse)(nil), // 24: memos.api.v1.ListLinkedIdentitiesResponse
|
||||
(*CreateLinkedIdentityRequest)(nil), // 25: memos.api.v1.CreateLinkedIdentityRequest
|
||||
(*GetLinkedIdentityRequest)(nil), // 26: memos.api.v1.GetLinkedIdentityRequest
|
||||
(*DeleteLinkedIdentityRequest)(nil), // 27: memos.api.v1.DeleteLinkedIdentityRequest
|
||||
(*PersonalAccessToken)(nil), // 28: memos.api.v1.PersonalAccessToken
|
||||
(*ListPersonalAccessTokensRequest)(nil), // 29: memos.api.v1.ListPersonalAccessTokensRequest
|
||||
(*ListPersonalAccessTokensResponse)(nil), // 30: memos.api.v1.ListPersonalAccessTokensResponse
|
||||
(*CreatePersonalAccessTokenRequest)(nil), // 31: memos.api.v1.CreatePersonalAccessTokenRequest
|
||||
(*CreatePersonalAccessTokenResponse)(nil), // 32: memos.api.v1.CreatePersonalAccessTokenResponse
|
||||
(*DeletePersonalAccessTokenRequest)(nil), // 33: memos.api.v1.DeletePersonalAccessTokenRequest
|
||||
(*UserWebhook)(nil), // 34: memos.api.v1.UserWebhook
|
||||
(*ListUserWebhooksRequest)(nil), // 35: memos.api.v1.ListUserWebhooksRequest
|
||||
(*ListUserWebhooksResponse)(nil), // 36: memos.api.v1.ListUserWebhooksResponse
|
||||
(*CreateUserWebhookRequest)(nil), // 37: memos.api.v1.CreateUserWebhookRequest
|
||||
(*UpdateUserWebhookRequest)(nil), // 38: memos.api.v1.UpdateUserWebhookRequest
|
||||
(*DeleteUserWebhookRequest)(nil), // 39: memos.api.v1.DeleteUserWebhookRequest
|
||||
(*GetUserWebhookSigningSecretRequest)(nil), // 40: memos.api.v1.GetUserWebhookSigningSecretRequest
|
||||
(*GetUserWebhookSigningSecretResponse)(nil), // 41: memos.api.v1.GetUserWebhookSigningSecretResponse
|
||||
(*UserNotification)(nil), // 42: memos.api.v1.UserNotification
|
||||
(*ListUserNotificationsRequest)(nil), // 43: memos.api.v1.ListUserNotificationsRequest
|
||||
(*ListUserNotificationsResponse)(nil), // 44: memos.api.v1.ListUserNotificationsResponse
|
||||
(*UpdateUserNotificationRequest)(nil), // 45: memos.api.v1.UpdateUserNotificationRequest
|
||||
(*DeleteUserNotificationRequest)(nil), // 46: memos.api.v1.DeleteUserNotificationRequest
|
||||
nil, // 47: memos.api.v1.UserStats.TagCountEntry
|
||||
(*UserStats_MemoTypeStats)(nil), // 48: memos.api.v1.UserStats.MemoTypeStats
|
||||
(*UserSetting_GeneralSetting)(nil), // 49: memos.api.v1.UserSetting.GeneralSetting
|
||||
(*UserSetting_TagMetadata)(nil), // 50: memos.api.v1.UserSetting.TagMetadata
|
||||
(*UserSetting_TagsSetting)(nil), // 51: memos.api.v1.UserSetting.TagsSetting
|
||||
(*UserSetting_WebhooksSetting)(nil), // 52: memos.api.v1.UserSetting.WebhooksSetting
|
||||
nil, // 53: memos.api.v1.UserSetting.TagsSetting.TagsEntry
|
||||
(*UserNotification_MemoCommentPayload)(nil), // 54: memos.api.v1.UserNotification.MemoCommentPayload
|
||||
(*UserNotification_MemoMentionPayload)(nil), // 55: memos.api.v1.UserNotification.MemoMentionPayload
|
||||
(State)(0), // 56: memos.api.v1.State
|
||||
(*timestamppb.Timestamp)(nil), // 57: google.protobuf.Timestamp
|
||||
(*fieldmaskpb.FieldMask)(nil), // 58: google.protobuf.FieldMask
|
||||
(*color.Color)(nil), // 59: google.type.Color
|
||||
(*emptypb.Empty)(nil), // 60: google.protobuf.Empty
|
||||
(User_Role)(0), // 0: memos.api.v1.User.Role
|
||||
(UserSetting_Key)(0), // 1: memos.api.v1.UserSetting.Key
|
||||
(UserNotification_Status)(0), // 2: memos.api.v1.UserNotification.Status
|
||||
(UserNotification_Type)(0), // 3: memos.api.v1.UserNotification.Type
|
||||
(UserNotification_SpaceInvitationPayload_State)(0), // 4: memos.api.v1.UserNotification.SpaceInvitationPayload.State
|
||||
(*User)(nil), // 5: memos.api.v1.User
|
||||
(*ListUsersRequest)(nil), // 6: memos.api.v1.ListUsersRequest
|
||||
(*ListUsersResponse)(nil), // 7: memos.api.v1.ListUsersResponse
|
||||
(*BatchGetUsersRequest)(nil), // 8: memos.api.v1.BatchGetUsersRequest
|
||||
(*BatchGetUsersResponse)(nil), // 9: memos.api.v1.BatchGetUsersResponse
|
||||
(*GetUserRequest)(nil), // 10: memos.api.v1.GetUserRequest
|
||||
(*CreateUserRequest)(nil), // 11: memos.api.v1.CreateUserRequest
|
||||
(*UpdateUserRequest)(nil), // 12: memos.api.v1.UpdateUserRequest
|
||||
(*DeleteUserRequest)(nil), // 13: memos.api.v1.DeleteUserRequest
|
||||
(*UserStats)(nil), // 14: memos.api.v1.UserStats
|
||||
(*GetUserStatsRequest)(nil), // 15: memos.api.v1.GetUserStatsRequest
|
||||
(*ListAllUserStatsRequest)(nil), // 16: memos.api.v1.ListAllUserStatsRequest
|
||||
(*ListAllUserStatsResponse)(nil), // 17: memos.api.v1.ListAllUserStatsResponse
|
||||
(*UserSetting)(nil), // 18: memos.api.v1.UserSetting
|
||||
(*GetUserSettingRequest)(nil), // 19: memos.api.v1.GetUserSettingRequest
|
||||
(*UpdateUserSettingRequest)(nil), // 20: memos.api.v1.UpdateUserSettingRequest
|
||||
(*ListUserSettingsRequest)(nil), // 21: memos.api.v1.ListUserSettingsRequest
|
||||
(*ListUserSettingsResponse)(nil), // 22: memos.api.v1.ListUserSettingsResponse
|
||||
(*LinkedIdentity)(nil), // 23: memos.api.v1.LinkedIdentity
|
||||
(*ListLinkedIdentitiesRequest)(nil), // 24: memos.api.v1.ListLinkedIdentitiesRequest
|
||||
(*ListLinkedIdentitiesResponse)(nil), // 25: memos.api.v1.ListLinkedIdentitiesResponse
|
||||
(*CreateLinkedIdentityRequest)(nil), // 26: memos.api.v1.CreateLinkedIdentityRequest
|
||||
(*GetLinkedIdentityRequest)(nil), // 27: memos.api.v1.GetLinkedIdentityRequest
|
||||
(*DeleteLinkedIdentityRequest)(nil), // 28: memos.api.v1.DeleteLinkedIdentityRequest
|
||||
(*PersonalAccessToken)(nil), // 29: memos.api.v1.PersonalAccessToken
|
||||
(*ListPersonalAccessTokensRequest)(nil), // 30: memos.api.v1.ListPersonalAccessTokensRequest
|
||||
(*ListPersonalAccessTokensResponse)(nil), // 31: memos.api.v1.ListPersonalAccessTokensResponse
|
||||
(*CreatePersonalAccessTokenRequest)(nil), // 32: memos.api.v1.CreatePersonalAccessTokenRequest
|
||||
(*CreatePersonalAccessTokenResponse)(nil), // 33: memos.api.v1.CreatePersonalAccessTokenResponse
|
||||
(*DeletePersonalAccessTokenRequest)(nil), // 34: memos.api.v1.DeletePersonalAccessTokenRequest
|
||||
(*UserWebhook)(nil), // 35: memos.api.v1.UserWebhook
|
||||
(*ListUserWebhooksRequest)(nil), // 36: memos.api.v1.ListUserWebhooksRequest
|
||||
(*ListUserWebhooksResponse)(nil), // 37: memos.api.v1.ListUserWebhooksResponse
|
||||
(*CreateUserWebhookRequest)(nil), // 38: memos.api.v1.CreateUserWebhookRequest
|
||||
(*UpdateUserWebhookRequest)(nil), // 39: memos.api.v1.UpdateUserWebhookRequest
|
||||
(*DeleteUserWebhookRequest)(nil), // 40: memos.api.v1.DeleteUserWebhookRequest
|
||||
(*GetUserWebhookSigningSecretRequest)(nil), // 41: memos.api.v1.GetUserWebhookSigningSecretRequest
|
||||
(*GetUserWebhookSigningSecretResponse)(nil), // 42: memos.api.v1.GetUserWebhookSigningSecretResponse
|
||||
(*UserNotification)(nil), // 43: memos.api.v1.UserNotification
|
||||
(*ListUserNotificationsRequest)(nil), // 44: memos.api.v1.ListUserNotificationsRequest
|
||||
(*ListUserNotificationsResponse)(nil), // 45: memos.api.v1.ListUserNotificationsResponse
|
||||
(*UpdateUserNotificationRequest)(nil), // 46: memos.api.v1.UpdateUserNotificationRequest
|
||||
(*DeleteUserNotificationRequest)(nil), // 47: memos.api.v1.DeleteUserNotificationRequest
|
||||
nil, // 48: memos.api.v1.UserStats.TagCountEntry
|
||||
(*UserStats_MemoTypeStats)(nil), // 49: memos.api.v1.UserStats.MemoTypeStats
|
||||
(*UserSetting_GeneralSetting)(nil), // 50: memos.api.v1.UserSetting.GeneralSetting
|
||||
(*UserSetting_TagMetadata)(nil), // 51: memos.api.v1.UserSetting.TagMetadata
|
||||
(*UserSetting_TagsSetting)(nil), // 52: memos.api.v1.UserSetting.TagsSetting
|
||||
(*UserSetting_WebhooksSetting)(nil), // 53: memos.api.v1.UserSetting.WebhooksSetting
|
||||
nil, // 54: memos.api.v1.UserSetting.TagsSetting.TagsEntry
|
||||
(*UserNotification_MemoCommentPayload)(nil), // 55: memos.api.v1.UserNotification.MemoCommentPayload
|
||||
(*UserNotification_MemoMentionPayload)(nil), // 56: memos.api.v1.UserNotification.MemoMentionPayload
|
||||
(*UserNotification_SpaceInvitationPayload)(nil), // 57: memos.api.v1.UserNotification.SpaceInvitationPayload
|
||||
(State)(0), // 58: memos.api.v1.State
|
||||
(*timestamppb.Timestamp)(nil), // 59: google.protobuf.Timestamp
|
||||
(*fieldmaskpb.FieldMask)(nil), // 60: google.protobuf.FieldMask
|
||||
(*color.Color)(nil), // 61: google.type.Color
|
||||
(*Space)(nil), // 62: memos.api.v1.Space
|
||||
(SpaceMember_Role)(0), // 63: memos.api.v1.SpaceMember.Role
|
||||
(*emptypb.Empty)(nil), // 64: google.protobuf.Empty
|
||||
}
|
||||
var file_api_v1_user_service_proto_depIdxs = []int32{
|
||||
0, // 0: memos.api.v1.User.role:type_name -> memos.api.v1.User.Role
|
||||
56, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State
|
||||
57, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp
|
||||
57, // 3: memos.api.v1.User.update_time:type_name -> google.protobuf.Timestamp
|
||||
4, // 4: memos.api.v1.ListUsersResponse.users:type_name -> memos.api.v1.User
|
||||
4, // 5: memos.api.v1.BatchGetUsersResponse.users:type_name -> memos.api.v1.User
|
||||
58, // 6: memos.api.v1.GetUserRequest.read_mask:type_name -> google.protobuf.FieldMask
|
||||
4, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User
|
||||
4, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
|
||||
58, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
48, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
|
||||
47, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
|
||||
57, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
|
||||
57, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
|
||||
56, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State
|
||||
13, // 15: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
|
||||
49, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
|
||||
52, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
|
||||
51, // 18: memos.api.v1.UserSetting.tags_setting:type_name -> memos.api.v1.UserSetting.TagsSetting
|
||||
17, // 19: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
|
||||
58, // 20: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
17, // 21: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
|
||||
22, // 22: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
|
||||
57, // 23: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
|
||||
57, // 24: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
|
||||
57, // 25: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
|
||||
28, // 26: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
|
||||
28, // 27: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
|
||||
57, // 28: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
|
||||
57, // 29: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
|
||||
34, // 30: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
34, // 31: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
34, // 32: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
58, // 33: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
4, // 34: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
|
||||
58, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State
|
||||
59, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp
|
||||
59, // 3: memos.api.v1.User.update_time:type_name -> google.protobuf.Timestamp
|
||||
5, // 4: memos.api.v1.ListUsersResponse.users:type_name -> memos.api.v1.User
|
||||
5, // 5: memos.api.v1.BatchGetUsersResponse.users:type_name -> memos.api.v1.User
|
||||
60, // 6: memos.api.v1.GetUserRequest.read_mask:type_name -> google.protobuf.FieldMask
|
||||
5, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User
|
||||
5, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
|
||||
60, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
49, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
|
||||
48, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
|
||||
59, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
|
||||
59, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
|
||||
58, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State
|
||||
14, // 15: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
|
||||
50, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
|
||||
53, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
|
||||
52, // 18: memos.api.v1.UserSetting.tags_setting:type_name -> memos.api.v1.UserSetting.TagsSetting
|
||||
18, // 19: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
|
||||
60, // 20: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
18, // 21: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
|
||||
23, // 22: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
|
||||
59, // 23: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
|
||||
59, // 24: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
|
||||
59, // 25: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
|
||||
29, // 26: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
|
||||
29, // 27: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
|
||||
59, // 28: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
|
||||
59, // 29: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
|
||||
35, // 30: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
35, // 31: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
35, // 32: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
60, // 33: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
5, // 34: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
|
||||
2, // 35: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status
|
||||
57, // 36: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
|
||||
59, // 36: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
|
||||
3, // 37: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type
|
||||
54, // 38: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
|
||||
55, // 39: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
|
||||
42, // 40: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
|
||||
42, // 41: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
|
||||
58, // 42: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
59, // 43: memos.api.v1.UserSetting.TagMetadata.background_color:type_name -> google.type.Color
|
||||
53, // 44: memos.api.v1.UserSetting.TagsSetting.tags:type_name -> memos.api.v1.UserSetting.TagsSetting.TagsEntry
|
||||
34, // 45: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
50, // 46: memos.api.v1.UserSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.UserSetting.TagMetadata
|
||||
5, // 47: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
|
||||
7, // 48: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
|
||||
9, // 49: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
|
||||
10, // 50: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
|
||||
11, // 51: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
|
||||
12, // 52: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
|
||||
15, // 53: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
|
||||
14, // 54: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
|
||||
18, // 55: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
|
||||
19, // 56: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
|
||||
20, // 57: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
|
||||
23, // 58: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
|
||||
25, // 59: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
|
||||
26, // 60: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
|
||||
27, // 61: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
|
||||
29, // 62: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
|
||||
31, // 63: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
|
||||
33, // 64: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
|
||||
35, // 65: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
|
||||
37, // 66: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
|
||||
38, // 67: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
|
||||
39, // 68: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
|
||||
40, // 69: memos.api.v1.UserService.GetUserWebhookSigningSecret:input_type -> memos.api.v1.GetUserWebhookSigningSecretRequest
|
||||
43, // 70: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
|
||||
45, // 71: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
|
||||
46, // 72: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
|
||||
6, // 73: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
|
||||
8, // 74: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
|
||||
4, // 75: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
|
||||
4, // 76: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
|
||||
4, // 77: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
|
||||
60, // 78: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
|
||||
16, // 79: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
|
||||
13, // 80: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
|
||||
17, // 81: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
17, // 82: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
21, // 83: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
|
||||
24, // 84: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
|
||||
22, // 85: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
22, // 86: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
60, // 87: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
|
||||
30, // 88: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
|
||||
32, // 89: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
|
||||
60, // 90: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
|
||||
36, // 91: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
|
||||
34, // 92: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
34, // 93: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
60, // 94: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
|
||||
41, // 95: memos.api.v1.UserService.GetUserWebhookSigningSecret:output_type -> memos.api.v1.GetUserWebhookSigningSecretResponse
|
||||
44, // 96: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
|
||||
42, // 97: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
|
||||
60, // 98: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
|
||||
73, // [73:99] is the sub-list for method output_type
|
||||
47, // [47:73] is the sub-list for method input_type
|
||||
47, // [47:47] is the sub-list for extension type_name
|
||||
47, // [47:47] is the sub-list for extension extendee
|
||||
0, // [0:47] is the sub-list for field type_name
|
||||
55, // 38: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
|
||||
56, // 39: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
|
||||
57, // 40: memos.api.v1.UserNotification.space_invitation:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload
|
||||
43, // 41: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
|
||||
43, // 42: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
|
||||
60, // 43: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
61, // 44: memos.api.v1.UserSetting.TagMetadata.background_color:type_name -> google.type.Color
|
||||
54, // 45: memos.api.v1.UserSetting.TagsSetting.tags:type_name -> memos.api.v1.UserSetting.TagsSetting.TagsEntry
|
||||
35, // 46: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
51, // 47: memos.api.v1.UserSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.UserSetting.TagMetadata
|
||||
62, // 48: memos.api.v1.UserNotification.SpaceInvitationPayload.space:type_name -> memos.api.v1.Space
|
||||
63, // 49: memos.api.v1.UserNotification.SpaceInvitationPayload.role:type_name -> memos.api.v1.SpaceMember.Role
|
||||
4, // 50: memos.api.v1.UserNotification.SpaceInvitationPayload.state:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload.State
|
||||
6, // 51: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
|
||||
8, // 52: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
|
||||
10, // 53: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
|
||||
11, // 54: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
|
||||
12, // 55: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
|
||||
13, // 56: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
|
||||
16, // 57: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
|
||||
15, // 58: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
|
||||
19, // 59: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
|
||||
20, // 60: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
|
||||
21, // 61: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
|
||||
24, // 62: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
|
||||
26, // 63: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
|
||||
27, // 64: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
|
||||
28, // 65: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
|
||||
30, // 66: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
|
||||
32, // 67: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
|
||||
34, // 68: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
|
||||
36, // 69: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
|
||||
38, // 70: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
|
||||
39, // 71: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
|
||||
40, // 72: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
|
||||
41, // 73: memos.api.v1.UserService.GetUserWebhookSigningSecret:input_type -> memos.api.v1.GetUserWebhookSigningSecretRequest
|
||||
44, // 74: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
|
||||
46, // 75: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
|
||||
47, // 76: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
|
||||
7, // 77: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
|
||||
9, // 78: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
|
||||
5, // 79: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
|
||||
5, // 80: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
|
||||
5, // 81: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
|
||||
64, // 82: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
|
||||
17, // 83: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
|
||||
14, // 84: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
|
||||
18, // 85: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
18, // 86: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
22, // 87: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
|
||||
25, // 88: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
|
||||
23, // 89: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
23, // 90: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
64, // 91: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
|
||||
31, // 92: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
|
||||
33, // 93: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
|
||||
64, // 94: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
|
||||
37, // 95: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
|
||||
35, // 96: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
35, // 97: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
64, // 98: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
|
||||
42, // 99: memos.api.v1.UserService.GetUserWebhookSigningSecret:output_type -> memos.api.v1.GetUserWebhookSigningSecretResponse
|
||||
45, // 100: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
|
||||
43, // 101: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
|
||||
64, // 102: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
|
||||
77, // [77:103] is the sub-list for method output_type
|
||||
51, // [51:77] is the sub-list for method input_type
|
||||
51, // [51:51] is the sub-list for extension type_name
|
||||
51, // [51:51] is the sub-list for extension extendee
|
||||
0, // [0:51] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_v1_user_service_proto_init() }
|
||||
|
|
@ -3861,6 +4024,7 @@ func file_api_v1_user_service_proto_init() {
|
|||
return
|
||||
}
|
||||
file_api_v1_common_proto_init()
|
||||
file_api_v1_space_service_proto_init()
|
||||
file_api_v1_user_service_proto_msgTypes[13].OneofWrappers = []any{
|
||||
(*UserSetting_GeneralSetting_)(nil),
|
||||
(*UserSetting_WebhooksSetting_)(nil),
|
||||
|
|
@ -3869,14 +4033,15 @@ func file_api_v1_user_service_proto_init() {
|
|||
file_api_v1_user_service_proto_msgTypes[38].OneofWrappers = []any{
|
||||
(*UserNotification_MemoComment)(nil),
|
||||
(*UserNotification_MemoMention)(nil),
|
||||
(*UserNotification_SpaceInvitation)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_user_service_proto_rawDesc), len(file_api_v1_user_service_proto_rawDesc)),
|
||||
NumEnums: 4,
|
||||
NumMessages: 52,
|
||||
NumEnums: 5,
|
||||
NumMessages: 53,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4667,6 +4667,7 @@ components:
|
|||
- TYPE_UNSPECIFIED
|
||||
- MEMO_COMMENT
|
||||
- MEMO_MENTION
|
||||
- SPACE_INVITATION
|
||||
type: string
|
||||
description: The type of the notification.
|
||||
format: enum
|
||||
|
|
@ -4678,6 +4679,10 @@ components:
|
|||
readOnly: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserNotification_MemoMentionPayload'
|
||||
spaceInvitation:
|
||||
readOnly: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserNotification_SpaceInvitationPayload'
|
||||
UserNotification_MemoCommentPayload:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -4716,6 +4721,34 @@ components:
|
|||
relatedMemoSnippet:
|
||||
type: string
|
||||
description: Preview text of the related parent memo.
|
||||
UserNotification_SpaceInvitationPayload:
|
||||
type: object
|
||||
properties:
|
||||
spaceInvitation:
|
||||
type: string
|
||||
description: |-
|
||||
The invitation resource.
|
||||
Format: spaces/{space}/invitations/{username}
|
||||
space:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Space'
|
||||
description: The read-only Space summary carried by the invitation.
|
||||
role:
|
||||
enum:
|
||||
- ROLE_UNSPECIFIED
|
||||
- ADMIN
|
||||
- USER
|
||||
type: string
|
||||
description: The role the invitation offers.
|
||||
format: enum
|
||||
state:
|
||||
enum:
|
||||
- STATE_UNSPECIFIED
|
||||
- PENDING
|
||||
- ACCEPTED
|
||||
type: string
|
||||
description: The current state of the invitation for the receiver.
|
||||
format: enum
|
||||
UserSetting:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const (
|
|||
InboxMessage_MEMO_COMMENT InboxMessage_Type = 1
|
||||
// Memo mention notification.
|
||||
InboxMessage_MEMO_MENTION InboxMessage_Type = 2
|
||||
// Space invitation notification.
|
||||
InboxMessage_SPACE_INVITATION InboxMessage_Type = 3
|
||||
)
|
||||
|
||||
// Enum value maps for InboxMessage_Type.
|
||||
|
|
@ -37,11 +39,13 @@ var (
|
|||
0: "TYPE_UNSPECIFIED",
|
||||
1: "MEMO_COMMENT",
|
||||
2: "MEMO_MENTION",
|
||||
3: "SPACE_INVITATION",
|
||||
}
|
||||
InboxMessage_Type_value = map[string]int32{
|
||||
"TYPE_UNSPECIFIED": 0,
|
||||
"MEMO_COMMENT": 1,
|
||||
"MEMO_MENTION": 2,
|
||||
"SPACE_INVITATION": 3,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -80,6 +84,7 @@ type InboxMessage struct {
|
|||
//
|
||||
// *InboxMessage_MemoComment
|
||||
// *InboxMessage_MemoMention
|
||||
// *InboxMessage_SpaceInvitation
|
||||
Payload isInboxMessage_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
|
@ -147,6 +152,15 @@ func (x *InboxMessage) GetMemoMention() *InboxMessage_MemoMentionPayload {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *InboxMessage) GetSpaceInvitation() *InboxMessage_SpaceInvitationPayload {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*InboxMessage_SpaceInvitation); ok {
|
||||
return x.SpaceInvitation
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isInboxMessage_Payload interface {
|
||||
isInboxMessage_Payload()
|
||||
}
|
||||
|
|
@ -159,10 +173,16 @@ type InboxMessage_MemoMention struct {
|
|||
MemoMention *InboxMessage_MemoMentionPayload `protobuf:"bytes,3,opt,name=memo_mention,json=memoMention,proto3,oneof"`
|
||||
}
|
||||
|
||||
type InboxMessage_SpaceInvitation struct {
|
||||
SpaceInvitation *InboxMessage_SpaceInvitationPayload `protobuf:"bytes,4,opt,name=space_invitation,json=spaceInvitation,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*InboxMessage_MemoComment) isInboxMessage_Payload() {}
|
||||
|
||||
func (*InboxMessage_MemoMention) isInboxMessage_Payload() {}
|
||||
|
||||
func (*InboxMessage_SpaceInvitation) isInboxMessage_Payload() {}
|
||||
|
||||
type InboxMessage_MemoCommentPayload struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
MemoId int32 `protobuf:"varint,1,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
|
||||
|
|
@ -267,25 +287,75 @@ func (x *InboxMessage_MemoMentionPayload) GetRelatedMemoId() int32 {
|
|||
return 0
|
||||
}
|
||||
|
||||
type InboxMessage_SpaceInvitationPayload struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The space the receiver was invited to join. The offered role and the
|
||||
// invitation state are resolved from the space membership at read time.
|
||||
SpaceId int32 `protobuf:"varint,1,opt,name=space_id,json=spaceId,proto3" json:"space_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *InboxMessage_SpaceInvitationPayload) Reset() {
|
||||
*x = InboxMessage_SpaceInvitationPayload{}
|
||||
mi := &file_store_inbox_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *InboxMessage_SpaceInvitationPayload) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*InboxMessage_SpaceInvitationPayload) ProtoMessage() {}
|
||||
|
||||
func (x *InboxMessage_SpaceInvitationPayload) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_store_inbox_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use InboxMessage_SpaceInvitationPayload.ProtoReflect.Descriptor instead.
|
||||
func (*InboxMessage_SpaceInvitationPayload) Descriptor() ([]byte, []int) {
|
||||
return file_store_inbox_proto_rawDescGZIP(), []int{0, 2}
|
||||
}
|
||||
|
||||
func (x *InboxMessage_SpaceInvitationPayload) GetSpaceId() int32 {
|
||||
if x != nil {
|
||||
return x.SpaceId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var File_store_inbox_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_store_inbox_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11store/inbox.proto\x12\vmemos.store\"\xe3\x03\n" +
|
||||
"\x11store/inbox.proto\x12\vmemos.store\"\x8d\x05\n" +
|
||||
"\fInboxMessage\x122\n" +
|
||||
"\x04type\x18\x01 \x01(\x0e2\x1e.memos.store.InboxMessage.TypeR\x04type\x12Q\n" +
|
||||
"\fmemo_comment\x18\x02 \x01(\v2,.memos.store.InboxMessage.MemoCommentPayloadH\x00R\vmemoComment\x12Q\n" +
|
||||
"\fmemo_mention\x18\x03 \x01(\v2,.memos.store.InboxMessage.MemoMentionPayloadH\x00R\vmemoMention\x1aU\n" +
|
||||
"\fmemo_mention\x18\x03 \x01(\v2,.memos.store.InboxMessage.MemoMentionPayloadH\x00R\vmemoMention\x12]\n" +
|
||||
"\x10space_invitation\x18\x04 \x01(\v20.memos.store.InboxMessage.SpaceInvitationPayloadH\x00R\x0fspaceInvitation\x1aU\n" +
|
||||
"\x12MemoCommentPayload\x12\x17\n" +
|
||||
"\amemo_id\x18\x01 \x01(\x05R\x06memoId\x12&\n" +
|
||||
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\x1aU\n" +
|
||||
"\x12MemoMentionPayload\x12\x17\n" +
|
||||
"\amemo_id\x18\x01 \x01(\x05R\x06memoId\x12&\n" +
|
||||
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\"@\n" +
|
||||
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\x1a3\n" +
|
||||
"\x16SpaceInvitationPayload\x12\x19\n" +
|
||||
"\bspace_id\x18\x01 \x01(\x05R\aspaceId\"V\n" +
|
||||
"\x04Type\x12\x14\n" +
|
||||
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
|
||||
"\fMEMO_COMMENT\x10\x01\x12\x10\n" +
|
||||
"\fMEMO_MENTION\x10\x02B\t\n" +
|
||||
"\fMEMO_MENTION\x10\x02\x12\x14\n" +
|
||||
"\x10SPACE_INVITATION\x10\x03B\t\n" +
|
||||
"\apayloadB\x95\x01\n" +
|
||||
"\x0fcom.memos.storeB\n" +
|
||||
"InboxProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3"
|
||||
|
|
@ -303,22 +373,24 @@ func file_store_inbox_proto_rawDescGZIP() []byte {
|
|||
}
|
||||
|
||||
var file_store_inbox_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_store_inbox_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_store_inbox_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_store_inbox_proto_goTypes = []any{
|
||||
(InboxMessage_Type)(0), // 0: memos.store.InboxMessage.Type
|
||||
(*InboxMessage)(nil), // 1: memos.store.InboxMessage
|
||||
(*InboxMessage_MemoCommentPayload)(nil), // 2: memos.store.InboxMessage.MemoCommentPayload
|
||||
(*InboxMessage_MemoMentionPayload)(nil), // 3: memos.store.InboxMessage.MemoMentionPayload
|
||||
(InboxMessage_Type)(0), // 0: memos.store.InboxMessage.Type
|
||||
(*InboxMessage)(nil), // 1: memos.store.InboxMessage
|
||||
(*InboxMessage_MemoCommentPayload)(nil), // 2: memos.store.InboxMessage.MemoCommentPayload
|
||||
(*InboxMessage_MemoMentionPayload)(nil), // 3: memos.store.InboxMessage.MemoMentionPayload
|
||||
(*InboxMessage_SpaceInvitationPayload)(nil), // 4: memos.store.InboxMessage.SpaceInvitationPayload
|
||||
}
|
||||
var file_store_inbox_proto_depIdxs = []int32{
|
||||
0, // 0: memos.store.InboxMessage.type:type_name -> memos.store.InboxMessage.Type
|
||||
2, // 1: memos.store.InboxMessage.memo_comment:type_name -> memos.store.InboxMessage.MemoCommentPayload
|
||||
3, // 2: memos.store.InboxMessage.memo_mention:type_name -> memos.store.InboxMessage.MemoMentionPayload
|
||||
3, // [3:3] is the sub-list for method output_type
|
||||
3, // [3:3] is the sub-list for method input_type
|
||||
3, // [3:3] is the sub-list for extension type_name
|
||||
3, // [3:3] is the sub-list for extension extendee
|
||||
0, // [0:3] is the sub-list for field type_name
|
||||
4, // 3: memos.store.InboxMessage.space_invitation:type_name -> memos.store.InboxMessage.SpaceInvitationPayload
|
||||
4, // [4:4] is the sub-list for method output_type
|
||||
4, // [4:4] is the sub-list for method input_type
|
||||
4, // [4:4] is the sub-list for extension type_name
|
||||
4, // [4:4] is the sub-list for extension extendee
|
||||
0, // [0:4] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_store_inbox_proto_init() }
|
||||
|
|
@ -329,6 +401,7 @@ func file_store_inbox_proto_init() {
|
|||
file_store_inbox_proto_msgTypes[0].OneofWrappers = []any{
|
||||
(*InboxMessage_MemoComment)(nil),
|
||||
(*InboxMessage_MemoMention)(nil),
|
||||
(*InboxMessage_SpaceInvitation)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
|
|
@ -336,7 +409,7 @@ func file_store_inbox_proto_init() {
|
|||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_inbox_proto_rawDesc), len(file_store_inbox_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 3,
|
||||
NumMessages: 4,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,11 +15,18 @@ message InboxMessage {
|
|||
int32 related_memo_id = 2;
|
||||
}
|
||||
|
||||
message SpaceInvitationPayload {
|
||||
// The space the receiver was invited to join. The offered role and the
|
||||
// invitation state are resolved from the space membership at read time.
|
||||
int32 space_id = 1;
|
||||
}
|
||||
|
||||
// The type of the inbox message.
|
||||
Type type = 1;
|
||||
oneof payload {
|
||||
MemoCommentPayload memo_comment = 2;
|
||||
MemoMentionPayload memo_mention = 3;
|
||||
SpaceInvitationPayload space_invitation = 4;
|
||||
}
|
||||
|
||||
enum Type {
|
||||
|
|
@ -28,5 +35,7 @@ message InboxMessage {
|
|||
MEMO_COMMENT = 1;
|
||||
// Memo mention notification.
|
||||
MEMO_MENTION = 2;
|
||||
// Space invitation notification.
|
||||
SPACE_INVITATION = 3;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func (d *EmailDispatcher) DispatchInboxEmail(ctx context.Context, inbox *store.I
|
|||
return errors.Wrap(err, "failed to get notification memos")
|
||||
}
|
||||
|
||||
message, err := d.buildInboxEmailMessage(inbox, receiver, sender, memosByID)
|
||||
message, err := d.buildInboxEmailMessage(ctx, inbox, receiver, sender, memosByID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -133,18 +133,73 @@ func SendTestEmail(setting *storepb.InstanceNotificationSetting_EmailSetting, re
|
|||
return email.Send(EmailConfigFromInstanceSetting(setting), NewTestEmailMessage(recipientEmail, setting.GetReplyTo()))
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) buildInboxEmailMessage(inbox *store.Inbox, receiver *store.User, sender *store.User, memosByID map[int32]*store.Memo) (*email.Message, error) {
|
||||
func (d *EmailDispatcher) buildInboxEmailMessage(ctx context.Context, inbox *store.Inbox, receiver *store.User, sender *store.User, memosByID map[int32]*store.Memo) (*email.Message, error) {
|
||||
senderName := displayNameForEmail(sender)
|
||||
switch inbox.Message.Type {
|
||||
case storepb.InboxMessage_MEMO_COMMENT:
|
||||
return d.buildMemoCommentEmailMessage(inbox.Message, receiver, senderName, memosByID)
|
||||
case storepb.InboxMessage_MEMO_MENTION:
|
||||
return d.buildMemoMentionEmailMessage(inbox.Message, receiver, senderName, memosByID)
|
||||
case storepb.InboxMessage_SPACE_INVITATION:
|
||||
return d.buildSpaceInvitationEmailMessage(ctx, inbox.Message, receiver, senderName)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildSpaceInvitationEmailMessage renders the invitation email only while
|
||||
// the receiver still holds the pending offer, so a revoked or already handled
|
||||
// invitation never reaches their mailbox.
|
||||
func (d *EmailDispatcher) buildSpaceInvitationEmailMessage(ctx context.Context, message *storepb.InboxMessage, receiver *store.User, senderName string) (*email.Message, error) {
|
||||
payload := message.GetSpaceInvitation()
|
||||
if payload == nil || payload.SpaceId <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
space, err := d.store.GetSpace(ctx, &store.FindSpace{ID: &payload.SpaceId})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get invitation space")
|
||||
}
|
||||
if space == nil {
|
||||
return nil, nil
|
||||
}
|
||||
invitation, err := d.store.GetSpaceInvitation(ctx, &store.FindSpaceInvitation{
|
||||
SpaceID: &space.ID,
|
||||
UserID: &receiver.ID,
|
||||
ViewerUserID: &receiver.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get space invitation")
|
||||
}
|
||||
if invitation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
url := d.inboxURL()
|
||||
if url == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
roleName := "a member"
|
||||
if invitation.Role == store.SpaceMemberRoleAdmin {
|
||||
roleName = "an administrator"
|
||||
}
|
||||
body := []string{
|
||||
fmt.Sprintf("Hi %s,", displayNameForEmail(receiver)),
|
||||
"",
|
||||
fmt.Sprintf("%s invited you to join the Space \"%s\" as %s.", senderName, space.Title, roleName),
|
||||
"",
|
||||
"Review the invitation in Memos:",
|
||||
url,
|
||||
"",
|
||||
"You are receiving this because an administrator of this Space invited you.",
|
||||
}
|
||||
|
||||
return &email.Message{
|
||||
To: []string{receiver.Email},
|
||||
Subject: fmt.Sprintf("[Memos] %s invited you to join \"%s\"", senderName, space.Title),
|
||||
Body: strings.Join(body, "\n"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) buildMemoCommentEmailMessage(message *storepb.InboxMessage, receiver *store.User, senderName string, memosByID map[int32]*store.Memo) (*email.Message, error) {
|
||||
payload := message.GetMemoComment()
|
||||
if payload == nil {
|
||||
|
|
@ -298,6 +353,14 @@ func (d *EmailDispatcher) baseURL() string {
|
|||
return strings.TrimRight(strings.TrimSpace(d.profile.InstanceURL), "/")
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) inboxURL() string {
|
||||
baseURL := d.baseURL()
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return baseURL + "/inbox"
|
||||
}
|
||||
|
||||
func (d *EmailDispatcher) memoURL(memo *store.Memo) string {
|
||||
baseURL := d.baseURL()
|
||||
if memo == nil || memo.UID == "" || baseURL == "" {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/internal/profile"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
teststore "github.com/usememos/memos/store/test"
|
||||
)
|
||||
|
|
@ -43,3 +45,41 @@ func TestEmailMemoReadCreatorLifecycle(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.NotContains(t, readable, danglingMemo.ID, "email rendering must fail closed for a missing memo creator")
|
||||
}
|
||||
|
||||
func TestEmailSpaceInvitationFailsClosedWithoutPendingOffer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := teststore.NewTestingStore(ctx, t)
|
||||
defer st.Close()
|
||||
dispatcher := NewEmailDispatcher(&profile.Profile{InstanceURL: "https://memos.example.com/"}, st, nil)
|
||||
|
||||
inviter, err := st.CreateUser(ctx, &store.User{Username: "email-space-inviter", Nickname: "Ada", Role: store.RoleUser, PasswordHash: "hash"})
|
||||
require.NoError(t, err)
|
||||
invitee, err := st.CreateUser(ctx, &store.User{Username: "email-space-invitee", Email: "invitee@example.com", Role: store.RoleUser, PasswordHash: "hash"})
|
||||
require.NoError(t, err)
|
||||
space, err := st.CreateSpace(ctx, &store.Space{UID: "email-space", Title: "Design team"}, inviter.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = st.CreateSpaceInvitation(ctx, &store.SpaceInvitation{SpaceID: space.ID, UserID: invitee.ID, Role: store.SpaceMemberRoleAdmin}, inviter.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
inbox := &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}},
|
||||
},
|
||||
}
|
||||
message, err := dispatcher.buildInboxEmailMessage(ctx, inbox, invitee, inviter, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, message)
|
||||
require.Equal(t, []string{"invitee@example.com"}, message.To)
|
||||
require.Equal(t, `[Memos] Ada invited you to join "Design team"`, message.Subject)
|
||||
require.Contains(t, message.Body, `Ada invited you to join the Space "Design team" as an administrator.`)
|
||||
require.Contains(t, message.Body, "https://memos.example.com/inbox")
|
||||
|
||||
require.NoError(t, st.DeclineSpaceInvitation(ctx, &store.DeclineSpaceInvitation{SpaceID: space.ID, UserID: invitee.ID}, invitee.ID))
|
||||
message, err = dispatcher.buildInboxEmailMessage(ctx, inbox, invitee, inviter, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, message, "a declined invitation must not be emailed")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ func (s *APIV1Service) CreateSpaceInvitation(ctx context.Context, request *v1pb.
|
|||
if err != nil {
|
||||
return nil, mapSpaceMutationError(err, "failed to create space invitation")
|
||||
}
|
||||
s.createSpaceInvitationNotification(ctx, space, currentUser, targetUser)
|
||||
s.SSEHub.publishSpaceChanged()
|
||||
return convertSpaceInvitationFromStore(space, targetUser, created), nil
|
||||
}
|
||||
|
|
@ -500,6 +501,7 @@ func (s *APIV1Service) DeleteSpaceInvitation(ctx context.Context, request *v1pb.
|
|||
}, currentUser.ID); err != nil {
|
||||
return nil, mapSpaceMutationError(err, "failed to revoke space invitation")
|
||||
}
|
||||
s.deleteSpaceInvitationNotifications(ctx, targetUser.ID, invitation.SpaceID)
|
||||
s.SSEHub.publishSpaceChanged()
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
|
@ -522,6 +524,7 @@ func (s *APIV1Service) AcceptSpaceInvitation(ctx context.Context, request *v1pb.
|
|||
if err != nil {
|
||||
return nil, mapSpaceMutationError(err, "failed to accept space invitation")
|
||||
}
|
||||
s.archiveSpaceInvitationNotifications(ctx, currentUser.ID, invitation.SpaceID)
|
||||
s.SSEHub.publishSpaceChanged()
|
||||
return convertSpaceMemberFromStore(space, currentUser, member), nil
|
||||
}
|
||||
|
|
@ -543,6 +546,7 @@ func (s *APIV1Service) DeclineSpaceInvitation(ctx context.Context, request *v1pb
|
|||
if err := s.Store.DeclineSpaceInvitation(ctx, &store.DeclineSpaceInvitation{SpaceID: invitation.SpaceID, UserID: currentUser.ID}, currentUser.ID); err != nil {
|
||||
return nil, mapSpaceMutationError(err, "failed to decline space invitation")
|
||||
}
|
||||
s.deleteSpaceInvitationNotifications(ctx, currentUser.ID, invitation.SpaceID)
|
||||
s.SSEHub.publishSpaceChanged()
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
|
|
|||
184
server/router/api/v1/space_service_notifications.go
Normal file
184
server/router/api/v1/space_service_notifications.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -409,3 +409,93 @@ func TestCreateSpaceInvitationClassifiesTargetUserErrors(t *testing.T) {
|
|||
})
|
||||
require.Equal(t, codes.Internal, status.Code(err))
|
||||
}
|
||||
|
||||
func listSpaceInvitationNotifications(ctx context.Context, t *testing.T, service *APIV1Service, user *store.User) []*v1pb.UserNotification {
|
||||
t.Helper()
|
||||
response, err := service.ListUserNotifications(userCtx(ctx, user.ID), &v1pb.ListUserNotificationsRequest{Parent: BuildUserName(user.Username)})
|
||||
require.NoError(t, err)
|
||||
notifications := []*v1pb.UserNotification{}
|
||||
for _, notification := range response.Notifications {
|
||||
if notification.Type == v1pb.UserNotification_SPACE_INVITATION {
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
}
|
||||
return notifications
|
||||
}
|
||||
|
||||
func TestSpaceInvitationNotificationLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newIntegrationService(t)
|
||||
owner := createSpaceTestUser(ctx, t, service, "notification-owner", store.RoleUser)
|
||||
invitee := createSpaceTestUser(ctx, t, service, "notification-invitee", store.RoleUser)
|
||||
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
|
||||
SpaceId: "notification-lifecycle",
|
||||
Space: &v1pb.Space{Title: "Notification lifecycle", Description: "Inbox coverage"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
invitation := inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_USER)
|
||||
notifications := listSpaceInvitationNotifications(ctx, t, service, invitee)
|
||||
require.Len(t, notifications, 1, "inviting a user must notify their inbox")
|
||||
notification := notifications[0]
|
||||
require.Equal(t, v1pb.UserNotification_UNREAD, notification.Status)
|
||||
require.Equal(t, BuildUserName(owner.Username), notification.Sender)
|
||||
payload := notification.GetSpaceInvitation()
|
||||
require.NotNil(t, payload)
|
||||
require.Equal(t, invitation.Name, payload.SpaceInvitation)
|
||||
require.Equal(t, v1pb.UserNotification_SpaceInvitationPayload_PENDING, payload.State)
|
||||
require.Equal(t, v1pb.SpaceMember_USER, payload.Role)
|
||||
require.Equal(t, space.Name, payload.Space.GetName())
|
||||
require.Equal(t, "Notification lifecycle", payload.Space.GetTitle())
|
||||
require.Equal(t, "Inbox coverage", payload.Space.GetDescription())
|
||||
require.Equal(t, v1pb.SpaceMember_ROLE_UNSPECIFIED, payload.Space.GetCurrentUserRole(), "an invitation carries a metadata-only Space summary")
|
||||
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, owner), "the inviter receives no notification")
|
||||
|
||||
_, err = service.DeclineSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.DeclineSpaceInvitationRequest{Name: invitation.Name})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "declining removes the notification")
|
||||
|
||||
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_ADMIN)
|
||||
require.Len(t, listSpaceInvitationNotifications(ctx, t, service, invitee), 1, "a later invitation notifies again")
|
||||
_, err = service.DeleteSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.DeleteSpaceInvitationRequest{Name: invitation.Name})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "revoking removes the notification")
|
||||
|
||||
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_ADMIN)
|
||||
notifications = listSpaceInvitationNotifications(ctx, t, service, invitee)
|
||||
require.Len(t, notifications, 1)
|
||||
pending := notifications[0]
|
||||
|
||||
archived, err := service.UpdateUserNotification(userCtx(ctx, invitee.ID), &v1pb.UpdateUserNotificationRequest{
|
||||
Notification: &v1pb.UserNotification{Name: pending.Name, Status: v1pb.UserNotification_ARCHIVED},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"status"}},
|
||||
})
|
||||
require.NoError(t, err, "the invitee may archive a pending invitation notification without acting on it")
|
||||
require.Equal(t, v1pb.UserNotification_SpaceInvitationPayload_PENDING, archived.GetSpaceInvitation().GetState())
|
||||
|
||||
_, err = service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
|
||||
require.NoError(t, err)
|
||||
notifications = listSpaceInvitationNotifications(ctx, t, service, invitee)
|
||||
require.Len(t, notifications, 1, "accepting keeps the notification as history")
|
||||
accepted := notifications[0]
|
||||
require.Equal(t, v1pb.UserNotification_ARCHIVED, accepted.Status, "accepting resolves the notification")
|
||||
require.Equal(t, v1pb.UserNotification_SpaceInvitationPayload_ACCEPTED, accepted.GetSpaceInvitation().GetState())
|
||||
require.Equal(t, v1pb.SpaceMember_ADMIN, accepted.GetSpaceInvitation().GetRole(), "the accepted state reflects the granted role")
|
||||
|
||||
_, err = service.DeleteSpaceMember(userCtx(ctx, invitee.ID), &v1pb.DeleteSpaceMemberRequest{Name: buildSpaceMemberName("notification-lifecycle", invitee.Username)})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "leaving the Space hides the accepted notification")
|
||||
_, err = service.UpdateUserNotification(userCtx(ctx, invitee.ID), &v1pb.UpdateUserNotificationRequest{
|
||||
Notification: &v1pb.UserNotification{Name: accepted.Name, Status: v1pb.UserNotification_UNREAD},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"status"}},
|
||||
})
|
||||
require.Equal(t, codes.NotFound, status.Code(err), "a hidden notification cannot be updated")
|
||||
|
||||
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_USER)
|
||||
_, err = service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listSpaceInvitationNotifications(ctx, t, service, invitee), 2, "re-joining reveals every accepted notification for the Space")
|
||||
_, err = service.DeleteSpace(userCtx(ctx, owner.ID), &v1pb.DeleteSpaceRequest{Name: space.Name})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "deleting the Space hides its notifications")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,10 +58,14 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.
|
|||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
|
||||
}
|
||||
spaceContext, err := s.loadSpaceInvitationNotificationContext(ctx, currentUser, inboxes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list notification spaces: %v", err)
|
||||
}
|
||||
|
||||
notifications := []*v1pb.UserNotification{}
|
||||
for _, inbox := range inboxes {
|
||||
notification, err := s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, currentUser, usersByID, memosByID)
|
||||
notification, err := s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, currentUser, usersByID, memosByID, spaceContext)
|
||||
if err != nil {
|
||||
if status.Code(err) == codes.NotFound {
|
||||
slog.Warn("Skipping notification with missing user",
|
||||
|
|
@ -125,7 +129,11 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb
|
|||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
|
||||
}
|
||||
accessible, err := s.canAccessNotificationMemos(ctx, currentUser, inbox, memosByID)
|
||||
spaceContext, err := s.loadSpaceInvitationNotificationContext(ctx, currentUser, []*store.Inbox{inbox})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list notification spaces: %v", err)
|
||||
}
|
||||
accessible, err := s.canAccessNotification(ctx, currentUser, inbox, memosByID, spaceContext)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to authorize notification: %v", err)
|
||||
}
|
||||
|
|
@ -227,7 +235,11 @@ func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox
|
|||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
|
||||
}
|
||||
return s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, viewer, usersByID, memosByID)
|
||||
spaceContext, err := s.loadSpaceInvitationNotificationContext(ctx, viewer, []*store.Inbox{inbox})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list notification spaces: %v", err)
|
||||
}
|
||||
return s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, viewer, usersByID, memosByID, spaceContext)
|
||||
}
|
||||
|
||||
func collectInboxMemoIDs(inboxes []*store.Inbox) []int32 {
|
||||
|
|
@ -264,8 +276,8 @@ func collectInboxMemoIDs(inboxes []*store.Inbox) []int32 {
|
|||
return memoIDs
|
||||
}
|
||||
|
||||
func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx context.Context, inbox *store.Inbox, viewer *store.User, usersByID map[int32]*store.User, memosByID map[int32]*store.Memo) (*v1pb.UserNotification, error) {
|
||||
accessible, err := s.canAccessNotificationMemos(ctx, viewer, inbox, memosByID)
|
||||
func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx context.Context, inbox *store.Inbox, viewer *store.User, usersByID map[int32]*store.User, memosByID map[int32]*store.Memo, spaceContext *spaceInvitationNotificationContext) (*v1pb.UserNotification, error) {
|
||||
accessible, err := s.canAccessNotification(ctx, viewer, inbox, memosByID, spaceContext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -323,6 +335,18 @@ func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx conte
|
|||
MemoMention: payload,
|
||||
}
|
||||
}
|
||||
case storepb.InboxMessage_SPACE_INVITATION:
|
||||
payload := convertSpaceInvitationNotificationPayload(inbox, viewer, spaceContext)
|
||||
if payload == nil {
|
||||
// The access check already required a pending or accepted
|
||||
// invitation; a missing payload means the space disappeared
|
||||
// in between. Fail closed.
|
||||
return nil, nil
|
||||
}
|
||||
notification.Type = v1pb.UserNotification_SPACE_INVITATION
|
||||
notification.Payload = &v1pb.UserNotification_SpaceInvitation{
|
||||
SpaceInvitation: payload,
|
||||
}
|
||||
default:
|
||||
notification.Type = v1pb.UserNotification_TYPE_UNSPECIFIED
|
||||
}
|
||||
|
|
@ -331,7 +355,11 @@ func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx conte
|
|||
return notification, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) canAccessNotificationMemos(ctx context.Context, viewer *store.User, inbox *store.Inbox, memosByID map[int32]*store.Memo) (bool, error) {
|
||||
// canAccessNotification reports whether the viewer may still read every
|
||||
// subject the notification refers to. Memo notifications require read access
|
||||
// to each memo; a space invitation requires the viewer to hold the pending
|
||||
// offer or to have accepted it.
|
||||
func (s *APIV1Service) canAccessNotification(ctx context.Context, viewer *store.User, inbox *store.Inbox, memosByID map[int32]*store.Memo, spaceContext *spaceInvitationNotificationContext) (bool, error) {
|
||||
if viewer == nil || viewer.RowStatus != store.Normal {
|
||||
return false, nil
|
||||
}
|
||||
|
|
@ -377,6 +405,9 @@ func (s *APIV1Service) canAccessNotificationMemos(ctx context.Context, viewer *s
|
|||
return canReadMemo, err
|
||||
}
|
||||
return check(payload.RelatedMemoId)
|
||||
case storepb.InboxMessage_SPACE_INVITATION:
|
||||
space, _, state := spaceContext.resolve(inbox.Message.GetSpaceInvitation().GetSpaceId())
|
||||
return space != nil && state != v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED, nil
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { useArchiveNotification, useDeleteNotification } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -15,28 +15,30 @@ interface Props {
|
|||
|
||||
function MemoCommentMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const archiveNotification = useArchiveNotification();
|
||||
const deleteNotification = useDeleteNotification();
|
||||
const navigateTo = useNavigateTo();
|
||||
const commentPayload = notification.payload?.case === "memoComment" ? notification.payload.value : undefined;
|
||||
const sender = notification.senderUser;
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
await userServiceClient.updateUserNotification({
|
||||
notification: {
|
||||
name: notification.name,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
});
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
try {
|
||||
await archiveNotification.mutateAsync(notification.name);
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Archive notification" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
await userServiceClient.deleteUserNotification({
|
||||
name: notification.name,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
try {
|
||||
await deleteNotification.mutateAsync(notification.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Delete notification" });
|
||||
}
|
||||
};
|
||||
|
||||
if (!commentPayload) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { AtSignIcon, CheckIcon, MessageSquareIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { useArchiveNotification, useDeleteNotification } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -15,28 +15,30 @@ interface Props {
|
|||
|
||||
function MemoMentionMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const archiveNotification = useArchiveNotification();
|
||||
const deleteNotification = useDeleteNotification();
|
||||
const navigateTo = useNavigateTo();
|
||||
const mentionPayload = notification.payload?.case === "memoMention" ? notification.payload.value : undefined;
|
||||
const sender = notification.senderUser;
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
await userServiceClient.updateUserNotification({
|
||||
notification: {
|
||||
name: notification.name,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
});
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
try {
|
||||
await archiveNotification.mutateAsync(notification.name);
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Archive notification" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
await userServiceClient.deleteUserNotification({
|
||||
name: notification.name,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
try {
|
||||
await deleteNotification.mutateAsync(notification.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Delete notification" });
|
||||
}
|
||||
};
|
||||
|
||||
if (!mentionPayload) {
|
||||
|
|
|
|||
200
web/src/components/Inbox/SpaceInvitationMessage.tsx
Normal file
200
web/src/components/Inbox/SpaceInvitationMessage.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { CheckIcon, TrashIcon, UsersIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import SpaceMark from "@/components/SpaceMark";
|
||||
import SpaceRoleBadge from "@/components/SpaceRoleBadge";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useAcceptSpaceInvitation, useDeclineSpaceInvitation } from "@/hooks/useSpaceQueries";
|
||||
import { useArchiveNotification, useDeleteNotification } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { extractSpaceUidFromName } from "@/lib/space-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
UserNotification,
|
||||
UserNotification_SpaceInvitationPayload_State,
|
||||
UserNotification_Status,
|
||||
} from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
notification: UserNotification;
|
||||
}
|
||||
|
||||
function SpaceInvitationMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const archiveNotification = useArchiveNotification();
|
||||
const deleteNotification = useDeleteNotification();
|
||||
const currentUser = useCurrentUser();
|
||||
const viewerName = currentUser?.name ?? "";
|
||||
const { selectSpace } = useSpaceContext();
|
||||
const acceptInvitation = useAcceptSpaceInvitation(viewerName);
|
||||
const declineInvitation = useDeclineSpaceInvitation(viewerName);
|
||||
const payload = notification.payload?.case === "spaceInvitation" ? notification.payload.value : undefined;
|
||||
const space = payload?.space;
|
||||
const sender = notification.senderUser;
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
try {
|
||||
await archiveNotification.mutateAsync(notification.name);
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Archive notification" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
try {
|
||||
await deleteNotification.mutateAsync(notification.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Delete notification" });
|
||||
}
|
||||
};
|
||||
|
||||
if (!payload || !space) {
|
||||
return (
|
||||
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
|
||||
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUnread = notification.status === UserNotification_Status.UNREAD;
|
||||
const isPending = payload.state === UserNotification_SpaceInvitationPayload_State.PENDING;
|
||||
const isBusy = acceptInvitation.isPending || declineInvitation.isPending;
|
||||
const spaceUid = extractSpaceUidFromName(space.name);
|
||||
const spaceLabel = `${space.title} (${spaceUid})`;
|
||||
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await acceptInvitation.mutateAsync({ name: payload.spaceInvitation });
|
||||
toast.success(t("setting.spaces.accept-success", { space: spaceLabel }));
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Accept space invitation" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
try {
|
||||
await declineInvitation.mutateAsync({ name: payload.spaceInvitation });
|
||||
toast.success(t("setting.spaces.decline-success"));
|
||||
} catch (error) {
|
||||
handleError(error, toast.error, { context: "Decline space invitation" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSpace = () => {
|
||||
selectSpace(space);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
|
||||
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
|
||||
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<UsersIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
|
||||
<span className="text-sm text-muted-foreground/80">{t("inbox.space-invitation")}</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
|
||||
at{" "}
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isUnread ? (
|
||||
<button
|
||||
onClick={() => handleArchiveMessage()}
|
||||
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.archive")}
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<SpaceMark size="lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-foreground/95">{space.title}</span>
|
||||
<span className="text-xs text-muted-foreground/70">{spaceUid}</span>
|
||||
<SpaceRoleBadge role={payload.role} />
|
||||
</div>
|
||||
<p className="line-clamp-1 text-xs text-muted-foreground/80">
|
||||
{isPending ? space.description || t("setting.spaces.invited-to-join") : t("inbox.space-invitation-accepted")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" disabled={isBusy} onClick={() => void handleDecline()}>
|
||||
{t("setting.spaces.decline")}
|
||||
</Button>
|
||||
<Button size="sm" disabled={isBusy} onClick={() => void handleAccept()}>
|
||||
{t("setting.spaces.accept")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenSpace}>
|
||||
{t("inbox.space-invitation-open")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpaceInvitationMessage;
|
||||
|
|
@ -17,6 +17,7 @@ import CreateSpaceDialog from "@/components/CreateSpaceDialog";
|
|||
import InviteSpaceMemberDialog from "@/components/Settings/InviteSpaceMemberDialog";
|
||||
import SettingSection from "@/components/Settings/SettingSection";
|
||||
import SpaceMark from "@/components/SpaceMark";
|
||||
import SpaceRoleBadge from "@/components/SpaceRoleBadge";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -56,16 +57,6 @@ const getSpacesSettingsLocation = (spaceName?: string) => ({
|
|||
hash: "#spaces",
|
||||
});
|
||||
|
||||
const SpaceRoleBadge = ({ role }: { role: SpaceMember_Role }) => {
|
||||
const t = useTranslate();
|
||||
const isAdmin = role === SpaceMember_Role.ADMIN;
|
||||
return (
|
||||
<Badge variant={isAdmin ? "secondary" : "outline"} shape="pill" className="h-5 px-2 font-normal">
|
||||
{isAdmin ? t("setting.spaces.space-admin") : t("setting.spaces.space-user")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberIdentity = ({ member, user, isCurrentUser }: { member: SpaceMember; user?: User; isCurrentUser?: boolean }) => {
|
||||
const t = useTranslate();
|
||||
const username = user?.username || extractUsernameFromName(member.user);
|
||||
|
|
|
|||
15
web/src/components/SpaceRoleBadge.tsx
Normal file
15
web/src/components/SpaceRoleBadge.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const SpaceRoleBadge = ({ role }: { role: SpaceMember_Role }) => {
|
||||
const t = useTranslate();
|
||||
const isAdmin = role === SpaceMember_Role.ADMIN;
|
||||
return (
|
||||
<Badge variant={isAdmin ? "secondary" : "outline"} shape="pill" className="h-5 px-2 font-normal">
|
||||
{isAdmin ? t("setting.spaces.space-admin") : t("setting.spaces.space-user")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceRoleBadge;
|
||||
|
|
@ -472,6 +472,8 @@ function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType<typeof us
|
|||
|
||||
function invalidateAllLiveQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
queryClient.invalidateQueries({ queryKey: spaceKeys.all, refetchType: "active" });
|
||||
// Space invitations are delivered to the inbox and resolved with the same event.
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.notifications(), refetchType: "active" });
|
||||
invalidateLiveMemoQueries(queryClient);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,8 @@ export function useAcceptSpaceInvitation(viewerName: string) {
|
|||
// promote that summary into the member list; refresh the authoritative list.
|
||||
void queryClient.invalidateQueries({ queryKey: spaceKeys.list(viewerName), exact: true });
|
||||
invalidateMembershipSensitiveQueries(queryClient);
|
||||
// The server resolves the matching inbox notification.
|
||||
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -321,6 +323,8 @@ export function useDeclineSpaceInvitation(viewerName: string) {
|
|||
removeByName(invitations, name),
|
||||
);
|
||||
}
|
||||
// The server removes the matching inbox notification.
|
||||
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { type QueryClient, queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { memoViewServiceClient, userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { buildUserSettingName, userNamePrefix } from "@/lib/resource-names";
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
type ListAllUserStatsRequest,
|
||||
ListAllUserStatsRequestSchema,
|
||||
User,
|
||||
UserNotification,
|
||||
UserNotification_Status,
|
||||
UserSetting,
|
||||
UserSetting_GeneralSetting,
|
||||
UserSetting_Key,
|
||||
|
|
@ -104,6 +106,46 @@ export function useNotifications() {
|
|||
});
|
||||
}
|
||||
|
||||
const updateCachedNotifications = (queryClient: QueryClient, update: (notifications: UserNotification[]) => UserNotification[]) => {
|
||||
const notifications = queryClient.getQueryData<UserNotification[]>(userKeys.notifications());
|
||||
if (notifications !== undefined) {
|
||||
queryClient.setQueryData<UserNotification[]>(userKeys.notifications(), update(notifications));
|
||||
}
|
||||
};
|
||||
|
||||
export function useArchiveNotification() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
userServiceClient.updateUserNotification({
|
||||
notification: { name, status: UserNotification_Status.ARCHIVED },
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
updateCachedNotifications(queryClient, (notifications) =>
|
||||
notifications.map((notification) => (notification.name === updated.name ? updated : notification)),
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNotification() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
await userServiceClient.deleteUserNotification({ name });
|
||||
return name;
|
||||
},
|
||||
onSuccess: (name) => {
|
||||
updateCachedNotifications(queryClient, (notifications) => notifications.filter((notification) => notification.name !== name));
|
||||
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTagCounts(forCurrentUser = false) {
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,9 @@
|
|||
"memo-comment": "{{user}} has a comment on your {{memo}}.",
|
||||
"no-archived": "No archived notifications",
|
||||
"no-unread": "No unread notifications",
|
||||
"space-invitation": "invited you to join a space",
|
||||
"space-invitation-accepted": "You joined this space.",
|
||||
"space-invitation-open": "Open space",
|
||||
"unread": "Unread"
|
||||
},
|
||||
"live-update": {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { sortBy } from "lodash-es";
|
|||
import { BellIcon } from "lucide-react";
|
||||
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
|
||||
import MemoMentionMessage from "@/components/Inbox/MemoMentionMessage";
|
||||
import SpaceInvitationMessage from "@/components/Inbox/SpaceInvitationMessage";
|
||||
import Placeholder from "@/components/Placeholder";
|
||||
import { useAppSidebar } from "@/contexts/AppSidebarContext";
|
||||
import { useNotifications } from "@/hooks/useUserQueries";
|
||||
|
|
@ -62,6 +63,9 @@ const Inboxes = () => {
|
|||
if (notification.type === UserNotification_Type.MEMO_MENTION) {
|
||||
return <MemoMentionMessage key={notification.name} notification={notification} />;
|
||||
}
|
||||
if (notification.type === UserNotification_Type.SPACE_INVITATION) {
|
||||
return <SpaceInvitationMessage key={notification.name} notification={notification} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -38,6 +38,7 @@ vi.mock("@/hooks/useSpaceQueries", () => ({
|
|||
vi.mock("@/hooks/useUserQueries", () => ({
|
||||
userKeys: {
|
||||
stats: () => ["users", "stats"],
|
||||
notifications: () => ["users", "notifications"],
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
188
web/tests/space-invitation-message.test.tsx
Normal file
188
web/tests/space-invitation-message.test.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SpaceInvitationMessage from "@/components/Inbox/SpaceInvitationMessage";
|
||||
import { type Space, SpaceMember_Role, SpaceSchema } from "@/types/proto/api/v1/space_service_pb";
|
||||
import {
|
||||
type UserNotification,
|
||||
UserNotification_SpaceInvitationPayload_State,
|
||||
UserNotification_Status,
|
||||
UserNotification_Type,
|
||||
UserNotificationSchema,
|
||||
UserSchema,
|
||||
} from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
acceptInvitation: vi.fn(),
|
||||
declineInvitation: vi.fn(),
|
||||
selectSpace: vi.fn(),
|
||||
archiveNotification: vi.fn(),
|
||||
deleteNotification: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: { error: state.toastError, success: state.toastSuccess },
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
default: () => ({ name: "users/alice" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useSpaceQueries", () => ({
|
||||
useAcceptSpaceInvitation: () => ({ isPending: false, mutateAsync: state.acceptInvitation }),
|
||||
useDeclineSpaceInvitation: () => ({ isPending: false, mutateAsync: state.declineInvitation }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/SpaceContext", () => ({
|
||||
useSpaceContext: () => ({ selectSpace: state.selectSpace }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useUserQueries", () => ({
|
||||
useArchiveNotification: () => ({ isPending: false, mutateAsync: state.archiveNotification }),
|
||||
useDeleteNotification: () => ({ isPending: false, mutateAsync: state.deleteNotification }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/SpaceMark", () => ({
|
||||
default: () => <span aria-hidden>space-mark</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/UserAvatar", () => ({
|
||||
default: () => <span aria-hidden data-testid="user-avatar" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string, params?: Record<string, string>) => (params ? `${key}:${Object.values(params).join(",")}` : key),
|
||||
}));
|
||||
|
||||
const space: Space = create(SpaceSchema, { name: "spaces/design", title: "Design team", description: "Product design notes" });
|
||||
|
||||
const createNotification = (overrides: {
|
||||
state: UserNotification_SpaceInvitationPayload_State;
|
||||
status?: UserNotification_Status;
|
||||
role?: SpaceMember_Role;
|
||||
}): UserNotification =>
|
||||
create(UserNotificationSchema, {
|
||||
name: "users/alice/notifications/7",
|
||||
sender: "users/bob",
|
||||
senderUser: create(UserSchema, { name: "users/bob", username: "bob", displayName: "Bob" }),
|
||||
status: overrides.status ?? UserNotification_Status.UNREAD,
|
||||
type: UserNotification_Type.SPACE_INVITATION,
|
||||
payload: {
|
||||
case: "spaceInvitation",
|
||||
value: {
|
||||
spaceInvitation: "spaces/design/invitations/alice",
|
||||
space,
|
||||
role: overrides.role ?? SpaceMember_Role.USER,
|
||||
state: overrides.state,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("SpaceInvitationMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.acceptInvitation.mockResolvedValue({ name: "spaces/design/members/alice" });
|
||||
state.declineInvitation.mockResolvedValue("spaces/design/invitations/alice");
|
||||
});
|
||||
|
||||
it("offers accept and decline while the invitation is pending", async () => {
|
||||
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
|
||||
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("inbox.space-invitation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design team")).toBeInTheDocument();
|
||||
expect(screen.getByText("design")).toBeInTheDocument();
|
||||
expect(screen.getByText("Product design notes")).toBeInTheDocument();
|
||||
expect(screen.getByText("setting.spaces.space-user")).toBeInTheDocument();
|
||||
expect(screen.queryByText("inbox.space-invitation-open")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.accept" }));
|
||||
await waitFor(() => expect(state.acceptInvitation).toHaveBeenCalledWith({ name: "spaces/design/invitations/alice" }));
|
||||
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("setting.spaces.accept-success:Design team (design)"));
|
||||
});
|
||||
|
||||
it("declines the invitation from the inbox", async () => {
|
||||
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.decline" }));
|
||||
await waitFor(() => expect(state.declineInvitation).toHaveBeenCalledWith({ name: "spaces/design/invitations/alice" }));
|
||||
expect(state.acceptInvitation).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("setting.spaces.decline-success"));
|
||||
});
|
||||
|
||||
it("reports a failed accept without pretending it succeeded", async () => {
|
||||
state.acceptInvitation.mockRejectedValueOnce(new Error("space invitation not found"));
|
||||
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.accept" }));
|
||||
await waitFor(() => expect(state.toastError).toHaveBeenCalled());
|
||||
expect(state.toastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the accepted state with a shortcut into the space", () => {
|
||||
render(
|
||||
<SpaceInvitationMessage
|
||||
notification={createNotification({
|
||||
state: UserNotification_SpaceInvitationPayload_State.ACCEPTED,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
role: SpaceMember_Role.ADMIN,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("inbox.space-invitation-accepted")).toBeInTheDocument();
|
||||
expect(screen.getByText("setting.spaces.space-admin")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "setting.spaces.accept" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "setting.spaces.decline" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "inbox.space-invitation-open" }));
|
||||
expect(state.selectSpace).toHaveBeenCalledWith(space);
|
||||
});
|
||||
|
||||
it("archives through the shared notification mutation and reports failures", async () => {
|
||||
state.archiveNotification.mockResolvedValueOnce(undefined);
|
||||
const { unmount } = render(
|
||||
<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.archive" }));
|
||||
await waitFor(() => expect(state.archiveNotification).toHaveBeenCalledWith("users/alice/notifications/7"));
|
||||
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("message.archived-successfully"));
|
||||
unmount();
|
||||
|
||||
state.archiveNotification.mockRejectedValueOnce(new Error("offline"));
|
||||
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.archive" }));
|
||||
await waitFor(() => expect(state.toastError).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("deletes an archived notification through the shared mutation", async () => {
|
||||
state.deleteNotification.mockResolvedValueOnce("users/alice/notifications/7");
|
||||
render(
|
||||
<SpaceInvitationMessage
|
||||
notification={createNotification({
|
||||
state: UserNotification_SpaceInvitationPayload_State.ACCEPTED,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.delete" }));
|
||||
await waitFor(() => expect(state.deleteNotification).toHaveBeenCalledWith("users/alice/notifications/7"));
|
||||
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("message.deleted-successfully"));
|
||||
});
|
||||
|
||||
it("falls back to the error row when the payload is missing", () => {
|
||||
const notification = create(UserNotificationSchema, {
|
||||
name: "users/alice/notifications/8",
|
||||
type: UserNotification_Type.SPACE_INVITATION,
|
||||
status: UserNotification_Status.UNREAD,
|
||||
});
|
||||
render(<SpaceInvitationMessage notification={notification} />);
|
||||
|
||||
expect(screen.getByText("inbox.failed-to-load")).toBeInTheDocument();
|
||||
expect(screen.queryByText("setting.spaces.accept")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue