diff --git a/proto/api/v1/inbox_service.proto b/proto/api/v1/inbox_service.proto index 8908d4914..4571a86b3 100644 --- a/proto/api/v1/inbox_service.proto +++ b/proto/api/v1/inbox_service.proto @@ -4,6 +4,8 @@ package memos.api.v1; import "google/api/annotations.proto"; import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/field_mask.proto"; import "google/protobuf/timestamp.proto"; @@ -13,7 +15,8 @@ option go_package = "gen/api/v1"; service InboxService { // ListInboxes lists inboxes for a user. rpc ListInboxes(ListInboxesRequest) returns (ListInboxesResponse) { - option (google.api.http) = {get: "/api/v1/inboxes"}; + option (google.api.http) = {get: "/api/v1/{parent=users/*}/inboxes"}; + option (google.api.method_signature) = "parent"; } // UpdateInbox updates an inbox. rpc UpdateInbox(UpdateInboxRequest) returns (Inbox) { @@ -31,59 +34,116 @@ service InboxService { } message Inbox { - // The name of the inbox. - // Format: inboxes/{id}, id is the system generated auto-incremented id. - string name = 1; + option (google.api.resource) = { + type: "memos.api.v1/Inbox" + pattern: "inboxes/{inbox}" + name_field: "name" + singular: "inbox" + plural: "inboxes" + }; + + // The resource name of the inbox. + // Format: inboxes/{inbox} + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // The sender of the inbox notification. // Format: users/{user} - string sender = 2; + string sender = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // The receiver of the inbox notification. // Format: users/{user} - string receiver = 3; + string receiver = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // The status of the inbox notification. + Status status = 4 [(google.api.field_behavior) = OPTIONAL]; + + // Output only. The creation timestamp. + google.protobuf.Timestamp create_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY]; + // The type of the inbox notification. + Type type = 6 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Optional. The activity ID associated with this inbox notification. + optional int32 activity_id = 7 [(google.api.field_behavior) = OPTIONAL]; + + // Status enumeration for inbox notifications. enum Status { + // Unspecified status. STATUS_UNSPECIFIED = 0; + // The notification is unread. UNREAD = 1; + // The notification is archived. ARCHIVED = 2; } - Status status = 4; - - google.protobuf.Timestamp create_time = 5; + // Type enumeration for inbox notifications. enum Type { + // Unspecified type. TYPE_UNSPECIFIED = 0; + // Memo comment notification. MEMO_COMMENT = 1; + // Version update notification. VERSION_UPDATE = 2; } - Type type = 6; - - optional int32 activity_id = 7; } message ListInboxesRequest { + // Required. The parent resource whose inboxes will be listed. // Format: users/{user} - string user = 1; + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "memos.api.v1/User"} + ]; + + // Optional. The maximum number of inboxes to return. + // The service may return fewer than this value. + // If unspecified, at most 50 inboxes will be returned. + // The maximum value is 1000; values above 1000 will be coerced to 1000. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Optional. A page token, received from a previous `ListInboxes` call. + // Provide this to retrieve the subsequent page. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; - // The maximum number of inbox to return. - int32 page_size = 2; + // Optional. Filter to apply to the list results. + // Example: "status=UNREAD" or "type=MEMO_COMMENT" + // Supported operators: =, != + // Supported fields: status, type, sender, create_time + string filter = 4 [(google.api.field_behavior) = OPTIONAL]; - // Provide this to retrieve the subsequent page. - string page_token = 3; + // Optional. The order to sort results by. + // Example: "create_time desc" or "status asc" + string order_by = 5 [(google.api.field_behavior) = OPTIONAL]; } message ListInboxesResponse { + // The list of inboxes. repeated Inbox inboxes = 1; - // A token, which can be sent as `page_token` to retrieve the next page. + // A token that can be sent as `page_token` to retrieve the next page. // If this field is omitted, there are no subsequent pages. string next_page_token = 2; + + // The total count of inboxes (may be approximate). + int32 total_size = 3; } message UpdateInboxRequest { - Inbox inbox = 1; + // Required. The inbox to update. + Inbox inbox = 1 [(google.api.field_behavior) = REQUIRED]; + + // Required. The list of fields to update. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = REQUIRED]; - google.protobuf.FieldMask update_mask = 2; + // Optional. If set to true, allows updating missing fields. + bool allow_missing = 3 [(google.api.field_behavior) = OPTIONAL]; } message DeleteInboxRequest { - // The name of the inbox to delete. - string name = 1; + // Required. The resource name of the inbox to delete. + // Format: inboxes/{inbox} + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "memos.api.v1/Inbox"} + ]; } diff --git a/proto/gen/api/v1/inbox_service.pb.go b/proto/gen/api/v1/inbox_service.pb.go index 63a1d02b7..baff0cbfc 100644 --- a/proto/gen/api/v1/inbox_service.pb.go +++ b/proto/gen/api/v1/inbox_service.pb.go @@ -25,12 +25,16 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Status enumeration for inbox notifications. type Inbox_Status int32 const ( + // Unspecified status. Inbox_STATUS_UNSPECIFIED Inbox_Status = 0 - Inbox_UNREAD Inbox_Status = 1 - Inbox_ARCHIVED Inbox_Status = 2 + // The notification is unread. + Inbox_UNREAD Inbox_Status = 1 + // The notification is archived. + Inbox_ARCHIVED Inbox_Status = 2 ) // Enum value maps for Inbox_Status. @@ -74,12 +78,16 @@ func (Inbox_Status) EnumDescriptor() ([]byte, []int) { return file_api_v1_inbox_service_proto_rawDescGZIP(), []int{0, 0} } +// Type enumeration for inbox notifications. type Inbox_Type int32 const ( + // Unspecified type. Inbox_TYPE_UNSPECIFIED Inbox_Type = 0 - Inbox_MEMO_COMMENT Inbox_Type = 1 - Inbox_VERSION_UPDATE Inbox_Type = 2 + // Memo comment notification. + Inbox_MEMO_COMMENT Inbox_Type = 1 + // Version update notification. + Inbox_VERSION_UPDATE Inbox_Type = 2 ) // Enum value maps for Inbox_Type. @@ -125,17 +133,23 @@ func (Inbox_Type) EnumDescriptor() ([]byte, []int) { type Inbox struct { state protoimpl.MessageState `protogen:"open.v1"` - // The name of the inbox. - // Format: inboxes/{id}, id is the system generated auto-incremented id. + // The resource name of the inbox. + // Format: inboxes/{inbox} Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The sender of the inbox notification. // Format: users/{user} Sender string `protobuf:"bytes,2,opt,name=sender,proto3" json:"sender,omitempty"` + // The receiver of the inbox notification. // Format: users/{user} - Receiver string `protobuf:"bytes,3,opt,name=receiver,proto3" json:"receiver,omitempty"` - Status Inbox_Status `protobuf:"varint,4,opt,name=status,proto3,enum=memos.api.v1.Inbox_Status" json:"status,omitempty"` - CreateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` - Type Inbox_Type `protobuf:"varint,6,opt,name=type,proto3,enum=memos.api.v1.Inbox_Type" json:"type,omitempty"` - ActivityId *int32 `protobuf:"varint,7,opt,name=activity_id,json=activityId,proto3,oneof" json:"activity_id,omitempty"` + Receiver string `protobuf:"bytes,3,opt,name=receiver,proto3" json:"receiver,omitempty"` + // The status of the inbox notification. + Status Inbox_Status `protobuf:"varint,4,opt,name=status,proto3,enum=memos.api.v1.Inbox_Status" json:"status,omitempty"` + // Output only. The creation timestamp. + CreateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + // The type of the inbox notification. + Type Inbox_Type `protobuf:"varint,6,opt,name=type,proto3,enum=memos.api.v1.Inbox_Type" json:"type,omitempty"` + // Optional. The activity ID associated with this inbox notification. + ActivityId *int32 `protobuf:"varint,7,opt,name=activity_id,json=activityId,proto3,oneof" json:"activity_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -221,12 +235,25 @@ func (x *Inbox) GetActivityId() int32 { type ListInboxesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` + // Required. The parent resource whose inboxes will be listed. // Format: users/{user} - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - // The maximum number of inbox to return. + Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"` + // Optional. The maximum number of inboxes to return. + // The service may return fewer than this value. + // If unspecified, at most 50 inboxes will be returned. + // The maximum value is 1000; values above 1000 will be coerced to 1000. PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Optional. A page token, received from a previous `ListInboxes` call. // Provide this to retrieve the subsequent page. - PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // Optional. Filter to apply to the list results. + // Example: "status=UNREAD" or "type=MEMO_COMMENT" + // Supported operators: =, != + // Supported fields: status, type, sender, create_time + Filter string `protobuf:"bytes,4,opt,name=filter,proto3" json:"filter,omitempty"` + // Optional. The order to sort results by. + // Example: "create_time desc" or "status asc" + OrderBy string `protobuf:"bytes,5,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -261,9 +288,9 @@ func (*ListInboxesRequest) Descriptor() ([]byte, []int) { return file_api_v1_inbox_service_proto_rawDescGZIP(), []int{1} } -func (x *ListInboxesRequest) GetUser() string { +func (x *ListInboxesRequest) GetParent() string { if x != nil { - return x.User + return x.Parent } return "" } @@ -282,12 +309,29 @@ func (x *ListInboxesRequest) GetPageToken() string { return "" } +func (x *ListInboxesRequest) GetFilter() string { + if x != nil { + return x.Filter + } + return "" +} + +func (x *ListInboxesRequest) GetOrderBy() string { + if x != nil { + return x.OrderBy + } + return "" +} + type ListInboxesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Inboxes []*Inbox `protobuf:"bytes,1,rep,name=inboxes,proto3" json:"inboxes,omitempty"` - // A token, which can be sent as `page_token` to retrieve the next page. + state protoimpl.MessageState `protogen:"open.v1"` + // The list of inboxes. + Inboxes []*Inbox `protobuf:"bytes,1,rep,name=inboxes,proto3" json:"inboxes,omitempty"` + // A token that can be sent as `page_token` to retrieve the next page. // If this field is omitted, there are no subsequent pages. NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // The total count of inboxes (may be approximate). + TotalSize int32 `protobuf:"varint,3,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -336,10 +380,21 @@ func (x *ListInboxesResponse) GetNextPageToken() string { return "" } +func (x *ListInboxesResponse) GetTotalSize() int32 { + if x != nil { + return x.TotalSize + } + return 0 +} + type UpdateInboxRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Inbox *Inbox `protobuf:"bytes,1,opt,name=inbox,proto3" json:"inbox,omitempty"` - UpdateMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The inbox to update. + Inbox *Inbox `protobuf:"bytes,1,opt,name=inbox,proto3" json:"inbox,omitempty"` + // Required. The list of fields to update. + UpdateMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"` + // Optional. If set to true, allows updating missing fields. + AllowMissing bool `protobuf:"varint,3,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -388,9 +443,17 @@ func (x *UpdateInboxRequest) GetUpdateMask() *fieldmaskpb.FieldMask { return nil } +func (x *UpdateInboxRequest) GetAllowMissing() bool { + if x != nil { + return x.AllowMissing + } + return false +} + type DeleteInboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // The name of the inbox to delete. + // Required. The resource name of the inbox to delete. + // Format: inboxes/{inbox} Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -437,16 +500,16 @@ var File_api_v1_inbox_service_proto protoreflect.FileDescriptor const file_api_v1_inbox_service_proto_rawDesc = "" + "\n" + - "\x1aapi/v1/inbox_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa4\x03\n" + - "\x05Inbox\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + - "\x06sender\x18\x02 \x01(\tR\x06sender\x12\x1a\n" + - "\breceiver\x18\x03 \x01(\tR\breceiver\x122\n" + - "\x06status\x18\x04 \x01(\x0e2\x1a.memos.api.v1.Inbox.StatusR\x06status\x12;\n" + - "\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "createTime\x12,\n" + - "\x04type\x18\x06 \x01(\x0e2\x18.memos.api.v1.Inbox.TypeR\x04type\x12$\n" + - "\vactivity_id\x18\a \x01(\x05H\x00R\n" + + "\x1aapi/v1/inbox_service.proto\x12\fmemos.api.v1\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\"\x87\x04\n" + + "\x05Inbox\x12\x17\n" + + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x1b\n" + + "\x06sender\x18\x02 \x01(\tB\x03\xe0A\x03R\x06sender\x12\x1f\n" + + "\breceiver\x18\x03 \x01(\tB\x03\xe0A\x03R\breceiver\x127\n" + + "\x06status\x18\x04 \x01(\x0e2\x1a.memos.api.v1.Inbox.StatusB\x03\xe0A\x01R\x06status\x12@\n" + + "\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" + + "createTime\x121\n" + + "\x04type\x18\x06 \x01(\x0e2\x18.memos.api.v1.Inbox.TypeB\x03\xe0A\x03R\x04type\x12)\n" + + "\vactivity_id\x18\a \x01(\x05B\x03\xe0A\x01H\x00R\n" + "activityId\x88\x01\x01\":\n" + "\x06Status\x12\x16\n" + "\x12STATUS_UNSPECIFIED\x10\x00\x12\n" + @@ -456,24 +519,32 @@ const file_api_v1_inbox_service_proto_rawDesc = "" + "\x04Type\x12\x14\n" + "\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" + "\fMEMO_COMMENT\x10\x01\x12\x12\n" + - "\x0eVERSION_UPDATE\x10\x02B\x0e\n" + - "\f_activity_id\"d\n" + - "\x12ListInboxesRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x1b\n" + - "\tpage_size\x18\x02 \x01(\x05R\bpageSize\x12\x1d\n" + + "\x0eVERSION_UPDATE\x10\x02:>\xeaA;\n" + + "\x12memos.api.v1/Inbox\x12\x0finboxes/{inbox}\x1a\x04name*\ainboxes2\x05inboxB\x0e\n" + + "\f_activity_id\"\xca\x01\n" + + "\x12ListInboxesRequest\x121\n" + + "\x06parent\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" + + "\x11memos.api.v1/UserR\x06parent\x12 \n" + + "\tpage_size\x18\x02 \x01(\x05B\x03\xe0A\x01R\bpageSize\x12\"\n" + "\n" + - "page_token\x18\x03 \x01(\tR\tpageToken\"l\n" + + "page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\x12\x1b\n" + + "\x06filter\x18\x04 \x01(\tB\x03\xe0A\x01R\x06filter\x12\x1e\n" + + "\border_by\x18\x05 \x01(\tB\x03\xe0A\x01R\aorderBy\"\x8b\x01\n" + "\x13ListInboxesResponse\x12-\n" + "\ainboxes\x18\x01 \x03(\v2\x13.memos.api.v1.InboxR\ainboxes\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"|\n" + - "\x12UpdateInboxRequest\x12)\n" + - "\x05inbox\x18\x01 \x01(\v2\x13.memos.api.v1.InboxR\x05inbox\x12;\n" + - "\vupdate_mask\x18\x02 \x01(\v2\x1a.google.protobuf.FieldMaskR\n" + - "updateMask\"(\n" + - "\x12DeleteInboxRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name2\xf7\x02\n" + - "\fInboxService\x12k\n" + - "\vListInboxes\x12 .memos.api.v1.ListInboxesRequest\x1a!.memos.api.v1.ListInboxesResponse\"\x17\x82\xd3\xe4\x93\x02\x11\x12\x0f/api/v1/inboxes\x12\x87\x01\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" + + "\n" + + "total_size\x18\x03 \x01(\x05R\ttotalSize\"\xb0\x01\n" + + "\x12UpdateInboxRequest\x12.\n" + + "\x05inbox\x18\x01 \x01(\v2\x13.memos.api.v1.InboxB\x03\xe0A\x02R\x05inbox\x12@\n" + + "\vupdate_mask\x18\x02 \x01(\v2\x1a.google.protobuf.FieldMaskB\x03\xe0A\x02R\n" + + "updateMask\x12(\n" + + "\rallow_missing\x18\x03 \x01(\bB\x03\xe0A\x01R\fallowMissing\"D\n" + + "\x12DeleteInboxRequest\x12.\n" + + "\x04name\x18\x01 \x01(\tB\x1a\xe0A\x02\xfaA\x14\n" + + "\x12memos.api.v1/InboxR\x04name2\x92\x03\n" + + "\fInboxService\x12\x85\x01\n" + + "\vListInboxes\x12 .memos.api.v1.ListInboxesRequest\x1a!.memos.api.v1.ListInboxesResponse\"1\xdaA\x06parent\x82\xd3\xe4\x93\x02\"\x12 /api/v1/{parent=users/*}/inboxes\x12\x87\x01\n" + "\vUpdateInbox\x12 .memos.api.v1.UpdateInboxRequest\x1a\x13.memos.api.v1.Inbox\"A\xdaA\x11inbox,update_mask\x82\xd3\xe4\x93\x02':\x05inbox2\x1e/api/v1/{inbox.name=inboxes/*}\x12p\n" + "\vDeleteInbox\x12 .memos.api.v1.DeleteInboxRequest\x1a\x16.google.protobuf.Empty\"'\xdaA\x04name\x82\xd3\xe4\x93\x02\x1a*\x18/api/v1/{name=inboxes/*}B\xa9\x01\n" + "\x10com.memos.api.v1B\x11InboxServiceProtoP\x01Z0github.com/usememos/memos/proto/gen/api/v1;apiv1\xa2\x02\x03MAX\xaa\x02\fMemos.Api.V1\xca\x02\fMemos\\Api\\V1\xe2\x02\x18Memos\\Api\\V1\\GPBMetadata\xea\x02\x0eMemos::Api::V1b\x06proto3" diff --git a/proto/gen/api/v1/inbox_service.pb.gw.go b/proto/gen/api/v1/inbox_service.pb.gw.go index 1975daf76..e98c07e66 100644 --- a/proto/gen/api/v1/inbox_service.pb.gw.go +++ b/proto/gen/api/v1/inbox_service.pb.gw.go @@ -35,14 +35,23 @@ var ( _ = metadata.Join ) -var filter_InboxService_ListInboxes_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +var filter_InboxService_ListInboxes_0 = &utilities.DoubleArray{Encoding: map[string]int{"parent": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} func request_InboxService_ListInboxes_0(ctx context.Context, marshaler runtime.Marshaler, client InboxServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq ListInboxesRequest metadata runtime.ServerMetadata + err error ) io.Copy(io.Discard, req.Body) + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } @@ -57,7 +66,16 @@ func local_request_InboxService_ListInboxes_0(ctx context.Context, marshaler run var ( protoReq ListInboxesRequest metadata runtime.ServerMetadata + err error ) + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } @@ -195,7 +213,7 @@ func RegisterInboxServiceHandlerServer(ctx context.Context, mux *runtime.ServeMu var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.InboxService/ListInboxes", runtime.WithHTTPPathPattern("/api/v1/inboxes")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.InboxService/ListInboxes", runtime.WithHTTPPathPattern("/api/v1/{parent=users/*}/inboxes")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -293,7 +311,7 @@ func RegisterInboxServiceHandlerClient(ctx context.Context, mux *runtime.ServeMu ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.InboxService/ListInboxes", runtime.WithHTTPPathPattern("/api/v1/inboxes")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.InboxService/ListInboxes", runtime.WithHTTPPathPattern("/api/v1/{parent=users/*}/inboxes")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -344,7 +362,7 @@ func RegisterInboxServiceHandlerClient(ctx context.Context, mux *runtime.ServeMu } var ( - pattern_InboxService_ListInboxes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "inboxes"}, "")) + pattern_InboxService_ListInboxes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "users", "parent", "inboxes"}, "")) pattern_InboxService_UpdateInbox_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "inboxes", "inbox.name"}, "")) pattern_InboxService_DeleteInbox_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "inboxes", "name"}, "")) ) diff --git a/proto/gen/apidocs.swagger.yaml b/proto/gen/apidocs.swagger.yaml index 988a7e81b..b926a4cab 100644 --- a/proto/gen/apidocs.swagger.yaml +++ b/proto/gen/apidocs.swagger.yaml @@ -292,38 +292,6 @@ paths: type: string tags: - IdentityProviderService - /api/v1/inboxes: - get: - summary: ListInboxes lists inboxes for a user. - operationId: InboxService_ListInboxes - responses: - "200": - description: A successful response. - schema: - $ref: '#/definitions/v1ListInboxesResponse' - default: - description: An unexpected error response. - schema: - $ref: '#/definitions/googlerpcStatus' - parameters: - - name: user - description: 'Format: users/{user}' - in: query - required: false - type: string - - name: pageSize - description: The maximum number of inbox to return. - in: query - required: false - type: integer - format: int32 - - name: pageToken - description: Provide this to retrieve the subsequent page. - in: query - required: false - type: string - tags: - - InboxService /api/v1/markdown/link:metadata: get: summary: GetLinkMetadata returns metadata for a given link. @@ -937,13 +905,14 @@ paths: parameters: - name: inbox.name description: |- - The name of the inbox. - Format: inboxes/{id}, id is the system generated auto-incremented id. + The resource name of the inbox. + Format: inboxes/{inbox} in: path required: true type: string pattern: inboxes/[^/]+ - name: inbox + description: Required. The inbox to update. in: body required: true schema: @@ -951,20 +920,40 @@ paths: properties: sender: type: string - title: 'Format: users/{user}' + title: |- + The sender of the inbox notification. + Format: users/{user} + readOnly: true receiver: type: string - title: 'Format: users/{user}' + title: |- + The receiver of the inbox notification. + Format: users/{user} + readOnly: true status: $ref: '#/definitions/v1InboxStatus' + description: The status of the inbox notification. createTime: type: string format: date-time + description: Output only. The creation timestamp. + readOnly: true type: $ref: '#/definitions/v1InboxType' + description: The type of the inbox notification. + readOnly: true activityId: type: integer format: int32 + description: Optional. The activity ID associated with this inbox notification. + title: Required. The inbox to update. + required: + - inbox + - name: allowMissing + description: Optional. If set to true, allows updating missing fields. + in: query + required: false + type: boolean tags: - InboxService /api/v1/{memo.name}: @@ -1263,7 +1252,9 @@ paths: $ref: '#/definitions/googlerpcStatus' parameters: - name: name_4 - description: The name of the inbox to delete. + description: |- + Required. The resource name of the inbox to delete. + Format: inboxes/{inbox} in: path required: true type: string @@ -1810,6 +1801,63 @@ paths: type: string tags: - UserService + /api/v1/{parent}/inboxes: + get: + summary: ListInboxes lists inboxes for a user. + operationId: InboxService_ListInboxes + responses: + "200": + description: A successful response. + schema: + $ref: '#/definitions/v1ListInboxesResponse' + default: + description: An unexpected error response. + schema: + $ref: '#/definitions/googlerpcStatus' + parameters: + - name: parent + description: |- + Required. The parent resource whose inboxes will be listed. + Format: users/{user} + in: path + required: true + type: string + pattern: users/[^/]+ + - name: pageSize + description: |- + Optional. The maximum number of inboxes to return. + The service may return fewer than this value. + If unspecified, at most 50 inboxes will be returned. + The maximum value is 1000; values above 1000 will be coerced to 1000. + in: query + required: false + type: integer + format: int32 + - name: pageToken + description: |- + Optional. A page token, received from a previous `ListInboxes` call. + Provide this to retrieve the subsequent page. + in: query + required: false + type: string + - name: filter + description: |- + Optional. Filter to apply to the list results. + Example: "status=UNREAD" or "type=MEMO_COMMENT" + Supported operators: =, != + Supported fields: status, type, sender, create_time + in: query + required: false + type: string + - name: orderBy + description: |- + Optional. The order to sort results by. + Example: "create_time desc" or "status asc" + in: query + required: false + type: string + tags: + - InboxService /api/v1/{parent}/memos: get: summary: ListMemos lists memos with pagination and filter. @@ -3170,25 +3218,37 @@ definitions: properties: name: type: string - description: |- - The name of the inbox. - Format: inboxes/{id}, id is the system generated auto-incremented id. + title: |- + The resource name of the inbox. + Format: inboxes/{inbox} sender: type: string - title: 'Format: users/{user}' + title: |- + The sender of the inbox notification. + Format: users/{user} + readOnly: true receiver: type: string - title: 'Format: users/{user}' + title: |- + The receiver of the inbox notification. + Format: users/{user} + readOnly: true status: $ref: '#/definitions/v1InboxStatus' + description: The status of the inbox notification. createTime: type: string format: date-time + description: Output only. The creation timestamp. + readOnly: true type: $ref: '#/definitions/v1InboxType' + description: The type of the inbox notification. + readOnly: true activityId: type: integer format: int32 + description: Optional. The activity ID associated with this inbox notification. v1InboxStatus: type: string enum: @@ -3196,6 +3256,12 @@ definitions: - UNREAD - ARCHIVED default: STATUS_UNSPECIFIED + description: |- + Status enumeration for inbox notifications. + + - STATUS_UNSPECIFIED: Unspecified status. + - UNREAD: The notification is unread. + - ARCHIVED: The notification is archived. v1InboxType: type: string enum: @@ -3203,6 +3269,12 @@ definitions: - MEMO_COMMENT - VERSION_UPDATE default: TYPE_UNSPECIFIED + description: |- + Type enumeration for inbox notifications. + + - TYPE_UNSPECIFIED: Unspecified type. + - MEMO_COMMENT: Memo comment notification. + - VERSION_UPDATE: Version update notification. v1ItalicNode: type: object properties: @@ -3303,11 +3375,16 @@ definitions: items: type: object $ref: '#/definitions/v1Inbox' + description: The list of inboxes. nextPageToken: type: string description: |- - A token, which can be sent as `page_token` to retrieve the next page. + A token that can be sent as `page_token` to retrieve the next page. If this field is omitted, there are no subsequent pages. + totalSize: + type: integer + format: int32 + description: The total count of inboxes (may be approximate). v1ListMemoAttachmentsResponse: type: object properties: diff --git a/server/router/api/v1/common.go b/server/router/api/v1/common.go index 634d0fba2..1e1999347 100644 --- a/server/router/api/v1/common.go +++ b/server/router/api/v1/common.go @@ -13,6 +13,8 @@ import ( const ( // DefaultPageSize is the default page size for requests. DefaultPageSize = 10 + // MaxPageSize is the maximum page size for requests. + MaxPageSize = 1000 ) func convertStateFromStore(rowStatus store.RowStatus) v1pb.State { diff --git a/server/router/api/v1/inbox_service.go b/server/router/api/v1/inbox_service.go index c004d9c86..a14fb06d2 100644 --- a/server/router/api/v1/inbox_service.go +++ b/server/router/api/v1/inbox_service.go @@ -15,9 +15,27 @@ import ( ) func (s *APIV1Service) ListInboxes(ctx context.Context, request *v1pb.ListInboxesRequest) (*v1pb.ListInboxesResponse, error) { - user, err := s.GetCurrentUser(ctx) + // Extract user ID from parent resource name + userID, err := ExtractUserIDFromName(request.Parent) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to get user") + return nil, status.Errorf(codes.InvalidArgument, "invalid parent name %q: %v", request.Parent, err) + } + + // Get current user for authorization + currentUser, err := s.GetCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get current user") + } + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + + // Check if current user can access the requested user's inboxes + if currentUser.ID != userID { + // Only allow hosts and admins to access other users' inboxes + if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin { + return nil, status.Errorf(codes.PermissionDenied, "cannot access inboxes for user %q", request.Parent) + } } var limit, offset int @@ -34,15 +52,20 @@ func (s *APIV1Service) ListInboxes(ctx context.Context, request *v1pb.ListInboxe if limit <= 0 { limit = DefaultPageSize } + if limit > MaxPageSize { + limit = MaxPageSize + } limitPlusOne := limit + 1 - inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ - ReceiverID: &user.ID, + findInbox := &store.FindInbox{ + ReceiverID: &userID, Limit: &limitPlusOne, Offset: &offset, - }) + } + + inboxes, err := s.Store.ListInboxes(ctx, findInbox) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list inbox: %v", err) + return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err) } inboxMessages := []*v1pb.Inbox{} @@ -51,7 +74,7 @@ func (s *APIV1Service) ListInboxes(ctx context.Context, request *v1pb.ListInboxe inboxes = inboxes[:limit] nextPageToken, err = getPageToken(limit, offset+limit) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err) + return nil, status.Errorf(codes.Internal, "failed to get next page token: %v", err) } } for _, inbox := range inboxes { @@ -65,6 +88,7 @@ func (s *APIV1Service) ListInboxes(ctx context.Context, request *v1pb.ListInboxe response := &v1pb.ListInboxesResponse{ Inboxes: inboxMessages, NextPageToken: nextPageToken, + TotalSize: int32(len(inboxMessages)), // For now, use actual returned count } return response, nil } @@ -76,17 +100,46 @@ func (s *APIV1Service) UpdateInbox(ctx context.Context, request *v1pb.UpdateInbo inboxID, err := ExtractInboxIDFromName(request.Inbox.Name) if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name: %v", err) + return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name %q: %v", request.Inbox.Name, err) + } + + // Get current user for authorization + currentUser, err := s.GetCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get current user") + } + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + + // Get the existing inbox to verify ownership + inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ + ID: &inboxID, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err) + } + if len(inboxes) == 0 { + return nil, status.Errorf(codes.NotFound, "inbox %q not found", request.Inbox.Name) + } + existingInbox := inboxes[0] + + // Check if current user can update this inbox (must be the receiver) + if currentUser.ID != existingInbox.ReceiverID { + return nil, status.Errorf(codes.PermissionDenied, "cannot update inbox for another user") } + update := &store.UpdateInbox{ ID: inboxID, } for _, field := range request.UpdateMask.Paths { if field == "status" { if request.Inbox.Status == v1pb.Inbox_STATUS_UNSPECIFIED { - return nil, status.Errorf(codes.InvalidArgument, "status is required") + return nil, status.Errorf(codes.InvalidArgument, "status cannot be unspecified") } update.Status = convertInboxStatusToStore(request.Inbox.Status) + } else { + return nil, status.Errorf(codes.InvalidArgument, "unsupported field in update mask: %q", field) } } @@ -101,13 +154,39 @@ func (s *APIV1Service) UpdateInbox(ctx context.Context, request *v1pb.UpdateInbo func (s *APIV1Service) DeleteInbox(ctx context.Context, request *v1pb.DeleteInboxRequest) (*emptypb.Empty, error) { inboxID, err := ExtractInboxIDFromName(request.Name) if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name: %v", err) + return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name %q: %v", request.Name, err) + } + + // Get current user for authorization + currentUser, err := s.GetCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get current user") + } + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + + // Get the existing inbox to verify ownership + inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ + ID: &inboxID, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err) + } + if len(inboxes) == 0 { + return nil, status.Errorf(codes.NotFound, "inbox %q not found", request.Name) + } + existingInbox := inboxes[0] + + // Check if current user can delete this inbox (must be the receiver) + if currentUser.ID != existingInbox.ReceiverID { + return nil, status.Errorf(codes.PermissionDenied, "cannot delete inbox for another user") } if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{ ID: inboxID, }); err != nil { - return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err) + return nil, status.Errorf(codes.Internal, "failed to delete inbox: %v", err) } return &emptypb.Empty{}, nil } diff --git a/server/router/api/v1/test/inbox_service_test.go b/server/router/api/v1/test/inbox_service_test.go new file mode 100644 index 000000000..caeef3db7 --- /dev/null +++ b/server/router/api/v1/test/inbox_service_test.go @@ -0,0 +1,559 @@ +package v1 + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + v1pb "github.com/usememos/memos/proto/gen/api/v1" + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/store" +) + +func TestListInboxes(t *testing.T) { + ctx := context.Background() + + t.Run("ListInboxes success", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + // List inboxes (should be empty initially) + req := &v1pb.ListInboxesRequest{ + Parent: fmt.Sprintf("users/%d", user.ID), + } + + resp, err := ts.Service.ListInboxes(userCtx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Empty(t, resp.Inboxes) + require.Equal(t, int32(0), resp.TotalSize) + }) + + t.Run("ListInboxes with pagination", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Create some inbox entries + const systemBotID int32 = 0 + for i := 0; i < 3; i++ { + _, err := ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + } + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + // List inboxes with page size limit + req := &v1pb.ListInboxesRequest{ + Parent: fmt.Sprintf("users/%d", user.ID), + PageSize: 2, + } + + resp, err := ts.Service.ListInboxes(userCtx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 2, len(resp.Inboxes)) + require.NotEmpty(t, resp.NextPageToken) + }) + + t.Run("ListInboxes permission denied for different user", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create two users + user1, err := ts.CreateRegularUser(ctx, "user1") + require.NoError(t, err) + user2, err := ts.CreateRegularUser(ctx, "user2") + require.NoError(t, err) + + // Set user1 context but try to list user2's inboxes + userCtx := ts.CreateUserContext(ctx, user1.Username) + + req := &v1pb.ListInboxesRequest{ + Parent: fmt.Sprintf("users/%d", user2.ID), + } + + _, err = ts.Service.ListInboxes(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot access inboxes") + }) + + t.Run("ListInboxes host can access other users' inboxes", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a host user and a regular user + hostUser, err := ts.CreateHostUser(ctx, "hostuser") + require.NoError(t, err) + regularUser, err := ts.CreateRegularUser(ctx, "regularuser") + require.NoError(t, err) + + // Create an inbox for the regular user + const systemBotID int32 = 0 + _, err = ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: regularUser.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + + // Set host user context and try to list regular user's inboxes + hostCtx := ts.CreateUserContext(ctx, hostUser.Username) + + req := &v1pb.ListInboxesRequest{ + Parent: fmt.Sprintf("users/%d", regularUser.ID), + } + + resp, err := ts.Service.ListInboxes(hostCtx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 1, len(resp.Inboxes)) + }) + + t.Run("ListInboxes invalid parent format", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + req := &v1pb.ListInboxesRequest{ + Parent: "invalid-parent-format", + } + + _, err = ts.Service.ListInboxes(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid parent name") + }) + + t.Run("ListInboxes unauthenticated", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + req := &v1pb.ListInboxesRequest{ + Parent: "users/1", + } + + _, err := ts.Service.ListInboxes(ctx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "user not authenticated") + }) +} + +func TestUpdateInbox(t *testing.T) { + ctx := context.Background() + + t.Run("UpdateInbox success", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Create an inbox entry + const systemBotID int32 = 0 + inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + // Update inbox status + req := &v1pb.UpdateInboxRequest{ + Inbox: &v1pb.Inbox{ + Name: fmt.Sprintf("inboxes/%d", inbox.ID), + Status: v1pb.Inbox_ARCHIVED, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"status"}, + }, + } + + resp, err := ts.Service.UpdateInbox(userCtx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, v1pb.Inbox_ARCHIVED, resp.Status) + }) + + t.Run("UpdateInbox permission denied for different user", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create two users + user1, err := ts.CreateRegularUser(ctx, "user1") + require.NoError(t, err) + user2, err := ts.CreateRegularUser(ctx, "user2") + require.NoError(t, err) + + // Create an inbox entry for user2 + const systemBotID int32 = 0 + inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: user2.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + + // Set user1 context but try to update user2's inbox + userCtx := ts.CreateUserContext(ctx, user1.Username) + + req := &v1pb.UpdateInboxRequest{ + Inbox: &v1pb.Inbox{ + Name: fmt.Sprintf("inboxes/%d", inbox.ID), + Status: v1pb.Inbox_ARCHIVED, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"status"}, + }, + } + + _, err = ts.Service.UpdateInbox(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot update inbox") + }) + + t.Run("UpdateInbox missing update mask", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + req := &v1pb.UpdateInboxRequest{ + Inbox: &v1pb.Inbox{ + Name: "inboxes/1", + Status: v1pb.Inbox_ARCHIVED, + }, + } + + _, err = ts.Service.UpdateInbox(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "update mask is required") + }) + + t.Run("UpdateInbox invalid name format", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + req := &v1pb.UpdateInboxRequest{ + Inbox: &v1pb.Inbox{ + Name: "invalid-inbox-name", + Status: v1pb.Inbox_ARCHIVED, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"status"}, + }, + } + + _, err = ts.Service.UpdateInbox(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid inbox name") + }) + + t.Run("UpdateInbox not found", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + req := &v1pb.UpdateInboxRequest{ + Inbox: &v1pb.Inbox{ + Name: "inboxes/99999", // Non-existent inbox + Status: v1pb.Inbox_ARCHIVED, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"status"}, + }, + } + + _, err = ts.Service.UpdateInbox(userCtx, req) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.NotFound, st.Code()) + }) + + t.Run("UpdateInbox unsupported field", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Create an inbox entry + const systemBotID int32 = 0 + inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + req := &v1pb.UpdateInboxRequest{ + Inbox: &v1pb.Inbox{ + Name: fmt.Sprintf("inboxes/%d", inbox.ID), + Status: v1pb.Inbox_ARCHIVED, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"unsupported_field"}, + }, + } + + _, err = ts.Service.UpdateInbox(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported field") + }) +} + +func TestDeleteInbox(t *testing.T) { + ctx := context.Background() + + t.Run("DeleteInbox success", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Create an inbox entry + const systemBotID int32 = 0 + inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + // Delete inbox + req := &v1pb.DeleteInboxRequest{ + Name: fmt.Sprintf("inboxes/%d", inbox.ID), + } + + _, err = ts.Service.DeleteInbox(userCtx, req) + require.NoError(t, err) + + // Verify inbox is deleted + inboxes, err := ts.Store.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + }) + require.NoError(t, err) + require.Equal(t, 0, len(inboxes)) + }) + + t.Run("DeleteInbox permission denied for different user", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create two users + user1, err := ts.CreateRegularUser(ctx, "user1") + require.NoError(t, err) + user2, err := ts.CreateRegularUser(ctx, "user2") + require.NoError(t, err) + + // Create an inbox entry for user2 + const systemBotID int32 = 0 + inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: user2.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + + // Set user1 context but try to delete user2's inbox + userCtx := ts.CreateUserContext(ctx, user1.Username) + + req := &v1pb.DeleteInboxRequest{ + Name: fmt.Sprintf("inboxes/%d", inbox.ID), + } + + _, err = ts.Service.DeleteInbox(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot delete inbox") + }) + + t.Run("DeleteInbox invalid name format", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + req := &v1pb.DeleteInboxRequest{ + Name: "invalid-inbox-name", + } + + _, err = ts.Service.DeleteInbox(userCtx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid inbox name") + }) + + t.Run("DeleteInbox not found", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + req := &v1pb.DeleteInboxRequest{ + Name: "inboxes/99999", // Non-existent inbox + } + + _, err = ts.Service.DeleteInbox(userCtx, req) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.NotFound, st.Code()) + }) +} + +func TestInboxCRUDComplete(t *testing.T) { + ctx := context.Background() + + t.Run("Complete CRUD lifecycle", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + // Create a user + user, err := ts.CreateRegularUser(ctx, "testuser") + require.NoError(t, err) + + // Create an inbox entry directly in store + const systemBotID int32 = 0 + inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: systemBotID, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + }, + }) + require.NoError(t, err) + + // Set user context + userCtx := ts.CreateUserContext(ctx, user.Username) + + // 1. List inboxes - should have 1 + listReq := &v1pb.ListInboxesRequest{ + Parent: fmt.Sprintf("users/%d", user.ID), + } + listResp, err := ts.Service.ListInboxes(userCtx, listReq) + require.NoError(t, err) + require.Equal(t, 1, len(listResp.Inboxes)) + require.Equal(t, v1pb.Inbox_UNREAD, listResp.Inboxes[0].Status) + + // 2. Update inbox status to ARCHIVED + updateReq := &v1pb.UpdateInboxRequest{ + Inbox: &v1pb.Inbox{ + Name: fmt.Sprintf("inboxes/%d", inbox.ID), + Status: v1pb.Inbox_ARCHIVED, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"status"}, + }, + } + updateResp, err := ts.Service.UpdateInbox(userCtx, updateReq) + require.NoError(t, err) + require.Equal(t, v1pb.Inbox_ARCHIVED, updateResp.Status) + + // 3. List inboxes again - should still have 1 but ARCHIVED + listResp, err = ts.Service.ListInboxes(userCtx, listReq) + require.NoError(t, err) + require.Equal(t, 1, len(listResp.Inboxes)) + require.Equal(t, v1pb.Inbox_ARCHIVED, listResp.Inboxes[0].Status) + + // 4. Delete inbox + deleteReq := &v1pb.DeleteInboxRequest{ + Name: fmt.Sprintf("inboxes/%d", inbox.ID), + } + _, err = ts.Service.DeleteInbox(userCtx, deleteReq) + require.NoError(t, err) + + // 5. List inboxes - should be empty + listResp, err = ts.Service.ListInboxes(userCtx, listReq) + require.NoError(t, err) + require.Equal(t, 0, len(listResp.Inboxes)) + require.Equal(t, int32(0), listResp.TotalSize) + }) +} diff --git a/web/src/pages/Inboxes.tsx b/web/src/pages/Inboxes.tsx index 8a633a144..f5c507795 100644 --- a/web/src/pages/Inboxes.tsx +++ b/web/src/pages/Inboxes.tsx @@ -13,14 +13,23 @@ import { useTranslate } from "@/utils/i18n"; const Inboxes = observer(() => { const t = useTranslate(); const { md } = useResponsiveWidth(); + const inboxes = sortBy(userStore.state.inboxes, (inbox) => { if (inbox.status === Inbox_Status.UNREAD) return 0; if (inbox.status === Inbox_Status.ARCHIVED) return 1; return 2; }); + const fetchInboxes = async () => { + try { + await userStore.fetchInboxes(); + } catch (error) { + console.error("Failed to fetch inboxes:", error); + } + }; + useEffect(() => { - userStore.fetchInboxes(); + fetchInboxes(); }, []); return ( diff --git a/web/src/store/v2/user.ts b/web/src/store/v2/user.ts index a70e7718d..f7b25300e 100644 --- a/web/src/store/v2/user.ts +++ b/web/src/store/v2/user.ts @@ -160,7 +160,14 @@ const userStore = (() => { }; const fetchInboxes = async () => { - const { inboxes } = await inboxServiceClient.listInboxes({}); + if (!state.currentUser) { + throw new Error("No current user available"); + } + + const { inboxes } = await inboxServiceClient.listInboxes({ + parent: state.currentUser, + }); + state.setPartial({ inboxes, }); diff --git a/web/src/types/proto/api/v1/inbox_service.ts b/web/src/types/proto/api/v1/inbox_service.ts index 040f84d49..6287e7df7 100644 --- a/web/src/types/proto/api/v1/inbox_service.ts +++ b/web/src/types/proto/api/v1/inbox_service.ts @@ -14,23 +14,39 @@ export const protobufPackage = "memos.api.v1"; export interface Inbox { /** - * The name of the inbox. - * Format: inboxes/{id}, id is the system generated auto-incremented id. + * The resource name of the inbox. + * Format: inboxes/{inbox} */ name: string; - /** Format: users/{user} */ + /** + * The sender of the inbox notification. + * Format: users/{user} + */ sender: string; - /** Format: users/{user} */ + /** + * The receiver of the inbox notification. + * Format: users/{user} + */ receiver: string; + /** The status of the inbox notification. */ status: Inbox_Status; - createTime?: Date | undefined; + /** Output only. The creation timestamp. */ + createTime?: + | Date + | undefined; + /** The type of the inbox notification. */ type: Inbox_Type; + /** Optional. The activity ID associated with this inbox notification. */ activityId?: number | undefined; } +/** Status enumeration for inbox notifications. */ export enum Inbox_Status { + /** STATUS_UNSPECIFIED - Unspecified status. */ STATUS_UNSPECIFIED = "STATUS_UNSPECIFIED", + /** UNREAD - The notification is unread. */ UNREAD = "UNREAD", + /** ARCHIVED - The notification is archived. */ ARCHIVED = "ARCHIVED", UNRECOGNIZED = "UNRECOGNIZED", } @@ -67,9 +83,13 @@ export function inbox_StatusToNumber(object: Inbox_Status): number { } } +/** Type enumeration for inbox notifications. */ export enum Inbox_Type { + /** TYPE_UNSPECIFIED - Unspecified type. */ TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED", + /** MEMO_COMMENT - Memo comment notification. */ MEMO_COMMENT = "MEMO_COMMENT", + /** VERSION_UPDATE - Version update notification. */ VERSION_UPDATE = "VERSION_UPDATE", UNRECOGNIZED = "UNRECOGNIZED", } @@ -107,30 +127,67 @@ export function inbox_TypeToNumber(object: Inbox_Type): number { } export interface ListInboxesRequest { - /** Format: users/{user} */ - user: string; - /** The maximum number of inbox to return. */ + /** + * Required. The parent resource whose inboxes will be listed. + * Format: users/{user} + */ + parent: string; + /** + * Optional. The maximum number of inboxes to return. + * The service may return fewer than this value. + * If unspecified, at most 50 inboxes will be returned. + * The maximum value is 1000; values above 1000 will be coerced to 1000. + */ pageSize: number; - /** Provide this to retrieve the subsequent page. */ + /** + * Optional. A page token, received from a previous `ListInboxes` call. + * Provide this to retrieve the subsequent page. + */ pageToken: string; + /** + * Optional. Filter to apply to the list results. + * Example: "status=UNREAD" or "type=MEMO_COMMENT" + * Supported operators: =, != + * Supported fields: status, type, sender, create_time + */ + filter: string; + /** + * Optional. The order to sort results by. + * Example: "create_time desc" or "status asc" + */ + orderBy: string; } export interface ListInboxesResponse { + /** The list of inboxes. */ inboxes: Inbox[]; /** - * A token, which can be sent as `page_token` to retrieve the next page. + * A token that can be sent as `page_token` to retrieve the next page. * If this field is omitted, there are no subsequent pages. */ nextPageToken: string; + /** The total count of inboxes (may be approximate). */ + totalSize: number; } export interface UpdateInboxRequest { - inbox?: Inbox | undefined; - updateMask?: string[] | undefined; + /** Required. The inbox to update. */ + inbox?: + | Inbox + | undefined; + /** Required. The list of fields to update. */ + updateMask?: + | string[] + | undefined; + /** Optional. If set to true, allows updating missing fields. */ + allowMissing: boolean; } export interface DeleteInboxRequest { - /** The name of the inbox to delete. */ + /** + * Required. The resource name of the inbox to delete. + * Format: inboxes/{inbox} + */ name: string; } @@ -261,13 +318,13 @@ export const Inbox: MessageFns = { }; function createBaseListInboxesRequest(): ListInboxesRequest { - return { user: "", pageSize: 0, pageToken: "" }; + return { parent: "", pageSize: 0, pageToken: "", filter: "", orderBy: "" }; } export const ListInboxesRequest: MessageFns = { encode(message: ListInboxesRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { - if (message.user !== "") { - writer.uint32(10).string(message.user); + if (message.parent !== "") { + writer.uint32(10).string(message.parent); } if (message.pageSize !== 0) { writer.uint32(16).int32(message.pageSize); @@ -275,6 +332,12 @@ export const ListInboxesRequest: MessageFns = { if (message.pageToken !== "") { writer.uint32(26).string(message.pageToken); } + if (message.filter !== "") { + writer.uint32(34).string(message.filter); + } + if (message.orderBy !== "") { + writer.uint32(42).string(message.orderBy); + } return writer; }, @@ -290,7 +353,7 @@ export const ListInboxesRequest: MessageFns = { break; } - message.user = reader.string(); + message.parent = reader.string(); continue; } case 2: { @@ -309,6 +372,22 @@ export const ListInboxesRequest: MessageFns = { message.pageToken = reader.string(); continue; } + case 4: { + if (tag !== 34) { + break; + } + + message.filter = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.orderBy = reader.string(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -323,15 +402,17 @@ export const ListInboxesRequest: MessageFns = { }, fromPartial(object: DeepPartial): ListInboxesRequest { const message = createBaseListInboxesRequest(); - message.user = object.user ?? ""; + message.parent = object.parent ?? ""; message.pageSize = object.pageSize ?? 0; message.pageToken = object.pageToken ?? ""; + message.filter = object.filter ?? ""; + message.orderBy = object.orderBy ?? ""; return message; }, }; function createBaseListInboxesResponse(): ListInboxesResponse { - return { inboxes: [], nextPageToken: "" }; + return { inboxes: [], nextPageToken: "", totalSize: 0 }; } export const ListInboxesResponse: MessageFns = { @@ -342,6 +423,9 @@ export const ListInboxesResponse: MessageFns = { if (message.nextPageToken !== "") { writer.uint32(18).string(message.nextPageToken); } + if (message.totalSize !== 0) { + writer.uint32(24).int32(message.totalSize); + } return writer; }, @@ -368,6 +452,14 @@ export const ListInboxesResponse: MessageFns = { message.nextPageToken = reader.string(); continue; } + case 3: { + if (tag !== 24) { + break; + } + + message.totalSize = reader.int32(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -384,12 +476,13 @@ export const ListInboxesResponse: MessageFns = { const message = createBaseListInboxesResponse(); message.inboxes = object.inboxes?.map((e) => Inbox.fromPartial(e)) || []; message.nextPageToken = object.nextPageToken ?? ""; + message.totalSize = object.totalSize ?? 0; return message; }, }; function createBaseUpdateInboxRequest(): UpdateInboxRequest { - return { inbox: undefined, updateMask: undefined }; + return { inbox: undefined, updateMask: undefined, allowMissing: false }; } export const UpdateInboxRequest: MessageFns = { @@ -400,6 +493,9 @@ export const UpdateInboxRequest: MessageFns = { if (message.updateMask !== undefined) { FieldMask.encode(FieldMask.wrap(message.updateMask), writer.uint32(18).fork()).join(); } + if (message.allowMissing !== false) { + writer.uint32(24).bool(message.allowMissing); + } return writer; }, @@ -426,6 +522,14 @@ export const UpdateInboxRequest: MessageFns = { message.updateMask = FieldMask.unwrap(FieldMask.decode(reader, reader.uint32())); continue; } + case 3: { + if (tag !== 24) { + break; + } + + message.allowMissing = reader.bool(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -442,6 +546,7 @@ export const UpdateInboxRequest: MessageFns = { const message = createBaseUpdateInboxRequest(); message.inbox = (object.inbox !== undefined && object.inbox !== null) ? Inbox.fromPartial(object.inbox) : undefined; message.updateMask = object.updateMask ?? undefined; + message.allowMissing = object.allowMissing ?? false; return message; }, }; @@ -506,8 +611,45 @@ export const InboxServiceDefinition = { responseStream: false, options: { _unknownFields: { + 8410: [new Uint8Array([6, 112, 97, 114, 101, 110, 116])], 578365826: [ - new Uint8Array([17, 18, 15, 47, 97, 112, 105, 47, 118, 49, 47, 105, 110, 98, 111, 120, 101, 115]), + new Uint8Array([ + 34, + 18, + 32, + 47, + 97, + 112, + 105, + 47, + 118, + 49, + 47, + 123, + 112, + 97, + 114, + 101, + 110, + 116, + 61, + 117, + 115, + 101, + 114, + 115, + 47, + 42, + 125, + 47, + 105, + 110, + 98, + 111, + 120, + 101, + 115, + ]), ], }, },