diff --git a/docs/design/multi-spaces.md b/docs/design/multi-spaces.md index fa455ad2..c8b0c21a 100644 --- a/docs/design/multi-spaces.md +++ b/docs/design/multi-spaces.md @@ -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. diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index 82e7f0c3..b48e0899 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -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; } } diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 528fdbfb..9888170a 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -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, }, diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index ec5f5b2e..3977c1e8 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -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: diff --git a/proto/gen/store/inbox.pb.go b/proto/gen/store/inbox.pb.go index ac797c95..d79612b4 100644 --- a/proto/gen/store/inbox.pb.go +++ b/proto/gen/store/inbox.pb.go @@ -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, }, diff --git a/proto/store/inbox.proto b/proto/store/inbox.proto index 99e906bb..9f6e6c9c 100644 --- a/proto/store/inbox.proto +++ b/proto/store/inbox.proto @@ -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; } } diff --git a/server/notification/email.go b/server/notification/email.go index 541768ab..93b3ed5b 100644 --- a/server/notification/email.go +++ b/server/notification/email.go @@ -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 == "" { diff --git a/server/notification/email_test.go b/server/notification/email_test.go index 85fc74b3..5cdc8f29 100644 --- a/server/notification/email_test.go +++ b/server/notification/email_test.go @@ -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") +} diff --git a/server/router/api/v1/space_service.go b/server/router/api/v1/space_service.go index 16b6ce17..8e1de110 100644 --- a/server/router/api/v1/space_service.go +++ b/server/router/api/v1/space_service.go @@ -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 } diff --git a/server/router/api/v1/space_service_notifications.go b/server/router/api/v1/space_service_notifications.go new file mode 100644 index 00000000..9dfff08e --- /dev/null +++ b/server/router/api/v1/space_service_notifications.go @@ -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, + } +} diff --git a/server/router/api/v1/space_service_test.go b/server/router/api/v1/space_service_test.go index de59c634..efc89fe9 100644 --- a/server/router/api/v1/space_service_test.go +++ b/server/router/api/v1/space_service_test.go @@ -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") +} diff --git a/server/router/api/v1/user_service_notifications.go b/server/router/api/v1/user_service_notifications.go index 41051bb7..6c6c1307 100644 --- a/server/router/api/v1/user_service_notifications.go +++ b/server/router/api/v1/user_service_notifications.go @@ -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 } diff --git a/web/src/components/Inbox/MemoCommentMessage.tsx b/web/src/components/Inbox/MemoCommentMessage.tsx index 76df6d05..8928e7d9 100644 --- a/web/src/components/Inbox/MemoCommentMessage.tsx +++ b/web/src/components/Inbox/MemoCommentMessage.tsx @@ -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) { diff --git a/web/src/components/Inbox/MemoMentionMessage.tsx b/web/src/components/Inbox/MemoMentionMessage.tsx index 9397deca..e725bdd9 100644 --- a/web/src/components/Inbox/MemoMentionMessage.tsx +++ b/web/src/components/Inbox/MemoMentionMessage.tsx @@ -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) { diff --git a/web/src/components/Inbox/SpaceInvitationMessage.tsx b/web/src/components/Inbox/SpaceInvitationMessage.tsx new file mode 100644 index 00000000..04fa081d --- /dev/null +++ b/web/src/components/Inbox/SpaceInvitationMessage.tsx @@ -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 ( +
+ {isPending ? space.description || t("setting.spaces.invited-to-join") : t("inbox.space-invitation-accepted")} +
+