refactor: inbox service

pull/4781/head
Steven 1 month ago
parent a4920d464b
commit 91c2a4cef9

@ -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"}
];
}

@ -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"

@ -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"}, ""))
)

@ -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:

@ -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 {

@ -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
}

@ -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)
})
}

@ -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 (

@ -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,
});

@ -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<Inbox> = {
};
function createBaseListInboxesRequest(): ListInboxesRequest {
return { user: "", pageSize: 0, pageToken: "" };
return { parent: "", pageSize: 0, pageToken: "", filter: "", orderBy: "" };
}
export const ListInboxesRequest: MessageFns<ListInboxesRequest> = {
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<ListInboxesRequest> = {
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<ListInboxesRequest> = {
break;
}
message.user = reader.string();
message.parent = reader.string();
continue;
}
case 2: {
@ -309,6 +372,22 @@ export const ListInboxesRequest: MessageFns<ListInboxesRequest> = {
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<ListInboxesRequest> = {
},
fromPartial(object: DeepPartial<ListInboxesRequest>): 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<ListInboxesResponse> = {
@ -342,6 +423,9 @@ export const ListInboxesResponse: MessageFns<ListInboxesResponse> = {
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<ListInboxesResponse> = {
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<ListInboxesResponse> = {
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<UpdateInboxRequest> = {
@ -400,6 +493,9 @@ export const UpdateInboxRequest: MessageFns<UpdateInboxRequest> = {
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<UpdateInboxRequest> = {
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<UpdateInboxRequest> = {
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,
]),
],
},
},

Loading…
Cancel
Save