From c479a91ea2eed52c4d49c2fe916ea3338b630501 Mon Sep 17 00:00:00 2001 From: ilhom Date: Wed, 15 Jul 2026 22:56:28 +0700 Subject: [PATCH 01/17] fix(finance): extend timeout for long-running RPCs like TriggerMbBatch The 60s TimeoutInterceptor killed the gRPC context mid-transaction when MB batch compute exceeded 60s, causing "context deadline exceeded" on the job completion update. Add a longRunningMethods map that gives batch compute and bulk import RPCs a 5-minute deadline instead of the default. Co-Authored-By: Claude Opus 4.6 --- .../internal/delivery/grpc/interceptors.go | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/services/finance/internal/delivery/grpc/interceptors.go b/services/finance/internal/delivery/grpc/interceptors.go index 24c3ea68..fd1ecda2 100644 --- a/services/finance/internal/delivery/grpc/interceptors.go +++ b/services/finance/internal/delivery/grpc/interceptors.go @@ -65,18 +65,35 @@ func RequestIDInterceptor() grpc.UnaryServerInterceptor { } } -// TimeoutInterceptor enforces request timeout. +// longRunningMethods maps gRPC method suffixes to extended timeouts for RPCs +// that routinely exceed the default 60s (batch compute, bulk import/export). +var longRunningMethods = map[string]time.Duration{ + "TriggerMbBatch": 5 * time.Minute, + "ExecutePushToHead": 5 * time.Minute, + "ProcessChunkInternal": 5 * time.Minute, + "ImportCostApplicableParams": 3 * time.Minute, + "ImportCostProductParameters": 3 * time.Minute, +} + +// TimeoutInterceptor enforces request timeout. RPCs listed in +// longRunningMethods get an extended deadline; all others use the default. func TimeoutInterceptor(timeout time.Duration) grpc.UnaryServerInterceptor { return func( ctx context.Context, req interface{}, - _ *grpc.UnaryServerInfo, + info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, ) (interface{}, error) { - // Check if context already has deadline if _, ok := ctx.Deadline(); !ok { + effective := timeout + for suffix, dur := range longRunningMethods { + if strings.HasSuffix(info.FullMethod, "/"+suffix) { + effective = dur + break + } + } var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, timeout) + ctx, cancel = context.WithTimeout(ctx, effective) defer cancel() } From 8bf041587d1577f46a09238f9850082ebffe44ac Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 05:03:38 +0700 Subject: [PATCH 02/17] feat(iam): add real-time chat system + AI chatbot backend Chat System: - Domain layer: entities, value objects, repository interfaces (AES-256-GCM) - Infrastructure: PostgreSQL repos, Redis pub/sub broadcaster, presence service - Application: 16 handlers (conversations + messaging + typing + streaming) - Delivery: gRPC handlers for ChatService + PresenceService - Migrations 000070-000076: 6 tables + menu seeds/permissions - main.go wired with conditional CHAT_MASTER_KEY activation Chatbot: - chatbot_audit_log repository for DeepSeek usage tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- gen/iam/v1/chat.pb.go | 3120 +++++++++++++++++ gen/iam/v1/chat.pb.gw.go | 1602 +++++++++ gen/iam/v1/chat_grpc.pb.go | 879 +++++ gen/openapi/iam/v1/chat.swagger.json | 1338 +++++++ services/iam/cmd/server/main.go | 59 + .../chat/add_participants_handler.go | 43 + .../application/chat/broadcast_helpers.go | 44 + .../application/chat/create_direct_handler.go | 51 + .../application/chat/create_group_handler.go | 42 + .../chat/delete_message_handler.go | 71 + .../application/chat/edit_message_handler.go | 75 + .../chat/get_conversation_handler.go | 33 + .../chat/leave_conversation_handler.go | 33 + .../chat/list_conversations_handler.go | 35 + .../application/chat/list_messages_handler.go | 72 + .../application/chat/mark_read_handler.go | 93 + .../chat/remove_participant_handler.go | 40 + .../application/chat/send_message_handler.go | 72 + .../application/chat/set_typing_handler.go | 64 + .../application/chat/stream_handler.go | 22 + .../application/chat/update_group_handler.go | 40 + .../internal/delivery/grpc/chat_handler.go | 377 ++ .../delivery/grpc/presence_handler.go | 62 + services/iam/internal/domain/chat/entity.go | 338 ++ .../iam/internal/domain/chat/entity_test.go | 62 + services/iam/internal/domain/chat/errors.go | 30 + .../iam/internal/domain/chat/repository.go | 71 + .../iam/internal/domain/chat/value_objects.go | 54 + .../infrastructure/chat/broadcaster.go | 270 ++ .../infrastructure/chat/broadcaster_test.go | 71 + .../internal/infrastructure/chat/presence.go | 140 + .../crypto/message_encryptor.go | 109 + .../crypto/message_encryptor_test.go | 58 + .../postgres/chat_conversation_repository.go | 259 ++ .../postgres/chat_message_repository.go | 350 ++ .../postgres/chatbot_audit_repository.go | 46 + .../000070_create_chat_conversation.down.sql | 1 + .../000070_create_chat_conversation.up.sql | 17 + .../000071_create_chat_participant.down.sql | 1 + .../000071_create_chat_participant.up.sql | 15 + .../000072_create_chat_message.down.sql | 1 + .../000072_create_chat_message.up.sql | 20 + ..._create_chat_message_edit_history.down.sql | 1 + ...73_create_chat_message_edit_history.up.sql | 11 + .../000074_create_chat_read_receipt.down.sql | 1 + .../000074_create_chat_read_receipt.up.sql | 10 + .../000075_create_chatbot_audit_log.down.sql | 1 + .../000075_create_chatbot_audit_log.up.sql | 15 + .../000076_chat_menu_permissions.down.sql | 32 + .../000076_chat_menu_permissions.up.sql | 71 + 50 files changed, 10322 insertions(+) create mode 100644 gen/iam/v1/chat.pb.go create mode 100644 gen/iam/v1/chat.pb.gw.go create mode 100644 gen/iam/v1/chat_grpc.pb.go create mode 100644 gen/openapi/iam/v1/chat.swagger.json create mode 100644 services/iam/internal/application/chat/add_participants_handler.go create mode 100644 services/iam/internal/application/chat/broadcast_helpers.go create mode 100644 services/iam/internal/application/chat/create_direct_handler.go create mode 100644 services/iam/internal/application/chat/create_group_handler.go create mode 100644 services/iam/internal/application/chat/delete_message_handler.go create mode 100644 services/iam/internal/application/chat/edit_message_handler.go create mode 100644 services/iam/internal/application/chat/get_conversation_handler.go create mode 100644 services/iam/internal/application/chat/leave_conversation_handler.go create mode 100644 services/iam/internal/application/chat/list_conversations_handler.go create mode 100644 services/iam/internal/application/chat/list_messages_handler.go create mode 100644 services/iam/internal/application/chat/mark_read_handler.go create mode 100644 services/iam/internal/application/chat/remove_participant_handler.go create mode 100644 services/iam/internal/application/chat/send_message_handler.go create mode 100644 services/iam/internal/application/chat/set_typing_handler.go create mode 100644 services/iam/internal/application/chat/stream_handler.go create mode 100644 services/iam/internal/application/chat/update_group_handler.go create mode 100644 services/iam/internal/delivery/grpc/chat_handler.go create mode 100644 services/iam/internal/delivery/grpc/presence_handler.go create mode 100644 services/iam/internal/domain/chat/entity.go create mode 100644 services/iam/internal/domain/chat/entity_test.go create mode 100644 services/iam/internal/domain/chat/errors.go create mode 100644 services/iam/internal/domain/chat/repository.go create mode 100644 services/iam/internal/domain/chat/value_objects.go create mode 100644 services/iam/internal/infrastructure/chat/broadcaster.go create mode 100644 services/iam/internal/infrastructure/chat/broadcaster_test.go create mode 100644 services/iam/internal/infrastructure/chat/presence.go create mode 100644 services/iam/internal/infrastructure/crypto/message_encryptor.go create mode 100644 services/iam/internal/infrastructure/crypto/message_encryptor_test.go create mode 100644 services/iam/internal/infrastructure/postgres/chat_conversation_repository.go create mode 100644 services/iam/internal/infrastructure/postgres/chat_message_repository.go create mode 100644 services/iam/internal/infrastructure/postgres/chatbot_audit_repository.go create mode 100644 services/iam/migrations/postgres/000070_create_chat_conversation.down.sql create mode 100644 services/iam/migrations/postgres/000070_create_chat_conversation.up.sql create mode 100644 services/iam/migrations/postgres/000071_create_chat_participant.down.sql create mode 100644 services/iam/migrations/postgres/000071_create_chat_participant.up.sql create mode 100644 services/iam/migrations/postgres/000072_create_chat_message.down.sql create mode 100644 services/iam/migrations/postgres/000072_create_chat_message.up.sql create mode 100644 services/iam/migrations/postgres/000073_create_chat_message_edit_history.down.sql create mode 100644 services/iam/migrations/postgres/000073_create_chat_message_edit_history.up.sql create mode 100644 services/iam/migrations/postgres/000074_create_chat_read_receipt.down.sql create mode 100644 services/iam/migrations/postgres/000074_create_chat_read_receipt.up.sql create mode 100644 services/iam/migrations/postgres/000075_create_chatbot_audit_log.down.sql create mode 100644 services/iam/migrations/postgres/000075_create_chatbot_audit_log.up.sql create mode 100644 services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql create mode 100644 services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql diff --git a/gen/iam/v1/chat.pb.go b/gen/iam/v1/chat.pb.go new file mode 100644 index 00000000..ed640ee3 --- /dev/null +++ b/gen/iam/v1/chat.pb.go @@ -0,0 +1,3120 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: iam/v1/chat.proto + +package iamv1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + v1 "github.com/mutugading/goapps-backend/gen/common/v1" + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ConversationProto represents a chat conversation (direct or group). +type ConversationProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + AvatarUrl string `protobuf:"bytes,4,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"` + Participants []*ParticipantProto `protobuf:"bytes,5,rep,name=participants,proto3" json:"participants,omitempty"` + LastMessage *MessageProto `protobuf:"bytes,6,opt,name=last_message,json=lastMessage,proto3" json:"last_message,omitempty"` + UnreadCount int32 `protobuf:"varint,7,opt,name=unread_count,json=unreadCount,proto3" json:"unread_count,omitempty"` + CreatedAt string `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt string `protobuf:"bytes,9,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConversationProto) Reset() { + *x = ConversationProto{} + mi := &file_iam_v1_chat_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConversationProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConversationProto) ProtoMessage() {} + +func (x *ConversationProto) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConversationProto.ProtoReflect.Descriptor instead. +func (*ConversationProto) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{0} +} + +func (x *ConversationProto) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ConversationProto) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ConversationProto) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ConversationProto) GetAvatarUrl() string { + if x != nil { + return x.AvatarUrl + } + return "" +} + +func (x *ConversationProto) GetParticipants() []*ParticipantProto { + if x != nil { + return x.Participants + } + return nil +} + +func (x *ConversationProto) GetLastMessage() *MessageProto { + if x != nil { + return x.LastMessage + } + return nil +} + +func (x *ConversationProto) GetUnreadCount() int32 { + if x != nil { + return x.UnreadCount + } + return 0 +} + +func (x *ConversationProto) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *ConversationProto) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +// ParticipantProto represents a user participating in a conversation. +type ParticipantProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + FullName string `protobuf:"bytes,3,opt,name=full_name,json=fullName,proto3" json:"full_name,omitempty"` + AvatarUrl string `protobuf:"bytes,4,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"` + Role string `protobuf:"bytes,5,opt,name=role,proto3" json:"role,omitempty"` + IsOnline bool `protobuf:"varint,6,opt,name=is_online,json=isOnline,proto3" json:"is_online,omitempty"` + JoinedAt string `protobuf:"bytes,7,opt,name=joined_at,json=joinedAt,proto3" json:"joined_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ParticipantProto) Reset() { + *x = ParticipantProto{} + mi := &file_iam_v1_chat_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ParticipantProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ParticipantProto) ProtoMessage() {} + +func (x *ParticipantProto) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ParticipantProto.ProtoReflect.Descriptor instead. +func (*ParticipantProto) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{1} +} + +func (x *ParticipantProto) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ParticipantProto) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ParticipantProto) GetFullName() string { + if x != nil { + return x.FullName + } + return "" +} + +func (x *ParticipantProto) GetAvatarUrl() string { + if x != nil { + return x.AvatarUrl + } + return "" +} + +func (x *ParticipantProto) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *ParticipantProto) GetIsOnline() bool { + if x != nil { + return x.IsOnline + } + return false +} + +func (x *ParticipantProto) GetJoinedAt() string { + if x != nil { + return x.JoinedAt + } + return "" +} + +// MessageProto represents a single chat message with decrypted body. +type MessageProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + MessageId string `protobuf:"bytes,1,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + SenderUserId string `protobuf:"bytes,3,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"` + SenderName string `protobuf:"bytes,4,opt,name=sender_name,json=senderName,proto3" json:"sender_name,omitempty"` + Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"` + IsEdited bool `protobuf:"varint,6,opt,name=is_edited,json=isEdited,proto3" json:"is_edited,omitempty"` + IsDeleted bool `protobuf:"varint,7,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"` + ReplyToId string `protobuf:"bytes,8,opt,name=reply_to_id,json=replyToId,proto3" json:"reply_to_id,omitempty"` + ReadReceipts []*ReadReceiptProto `protobuf:"bytes,9,rep,name=read_receipts,json=readReceipts,proto3" json:"read_receipts,omitempty"` + CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt string `protobuf:"bytes,11,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MessageProto) Reset() { + *x = MessageProto{} + mi := &file_iam_v1_chat_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MessageProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MessageProto) ProtoMessage() {} + +func (x *MessageProto) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MessageProto.ProtoReflect.Descriptor instead. +func (*MessageProto) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{2} +} + +func (x *MessageProto) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +func (x *MessageProto) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *MessageProto) GetSenderUserId() string { + if x != nil { + return x.SenderUserId + } + return "" +} + +func (x *MessageProto) GetSenderName() string { + if x != nil { + return x.SenderName + } + return "" +} + +func (x *MessageProto) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +func (x *MessageProto) GetIsEdited() bool { + if x != nil { + return x.IsEdited + } + return false +} + +func (x *MessageProto) GetIsDeleted() bool { + if x != nil { + return x.IsDeleted + } + return false +} + +func (x *MessageProto) GetReplyToId() string { + if x != nil { + return x.ReplyToId + } + return "" +} + +func (x *MessageProto) GetReadReceipts() []*ReadReceiptProto { + if x != nil { + return x.ReadReceipts + } + return nil +} + +func (x *MessageProto) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *MessageProto) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +// ReadReceiptProto represents a read receipt for a message. +type ReadReceiptProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ReadAt string `protobuf:"bytes,2,opt,name=read_at,json=readAt,proto3" json:"read_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadReceiptProto) Reset() { + *x = ReadReceiptProto{} + mi := &file_iam_v1_chat_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadReceiptProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadReceiptProto) ProtoMessage() {} + +func (x *ReadReceiptProto) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadReceiptProto.ProtoReflect.Descriptor instead. +func (*ReadReceiptProto) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{3} +} + +func (x *ReadReceiptProto) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ReadReceiptProto) GetReadAt() string { + if x != nil { + return x.ReadAt + } + return "" +} + +// EditHistoryEntryProto represents a single edit history entry for a message. +type EditHistoryEntryProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + HistoryId int64 `protobuf:"varint,1,opt,name=history_id,json=historyId,proto3" json:"history_id,omitempty"` + Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + EditedBy string `protobuf:"bytes,3,opt,name=edited_by,json=editedBy,proto3" json:"edited_by,omitempty"` + EditedAt string `protobuf:"bytes,4,opt,name=edited_at,json=editedAt,proto3" json:"edited_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditHistoryEntryProto) Reset() { + *x = EditHistoryEntryProto{} + mi := &file_iam_v1_chat_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditHistoryEntryProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditHistoryEntryProto) ProtoMessage() {} + +func (x *EditHistoryEntryProto) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EditHistoryEntryProto.ProtoReflect.Descriptor instead. +func (*EditHistoryEntryProto) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{4} +} + +func (x *EditHistoryEntryProto) GetHistoryId() int64 { + if x != nil { + return x.HistoryId + } + return 0 +} + +func (x *EditHistoryEntryProto) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +func (x *EditHistoryEntryProto) GetEditedBy() string { + if x != nil { + return x.EditedBy + } + return "" +} + +func (x *EditHistoryEntryProto) GetEditedAt() string { + if x != nil { + return x.EditedAt + } + return "" +} + +// CreateDirectConversationRequest creates a 1:1 direct conversation. +type CreateDirectConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerUserId string `protobuf:"bytes,1,opt,name=peer_user_id,json=peerUserId,proto3" json:"peer_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateDirectConversationRequest) Reset() { + *x = CreateDirectConversationRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateDirectConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateDirectConversationRequest) ProtoMessage() {} + +func (x *CreateDirectConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateDirectConversationRequest.ProtoReflect.Descriptor instead. +func (*CreateDirectConversationRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{5} +} + +func (x *CreateDirectConversationRequest) GetPeerUserId() string { + if x != nil { + return x.PeerUserId + } + return "" +} + +// CreateGroupConversationRequest creates a group conversation. +type CreateGroupConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + ParticipantIds []string `protobuf:"bytes,2,rep,name=participant_ids,json=participantIds,proto3" json:"participant_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateGroupConversationRequest) Reset() { + *x = CreateGroupConversationRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateGroupConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateGroupConversationRequest) ProtoMessage() {} + +func (x *CreateGroupConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateGroupConversationRequest.ProtoReflect.Descriptor instead. +func (*CreateGroupConversationRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{6} +} + +func (x *CreateGroupConversationRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateGroupConversationRequest) GetParticipantIds() []string { + if x != nil { + return x.ParticipantIds + } + return nil +} + +// GetConversationRequest retrieves a conversation by ID. +type GetConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConversationRequest) Reset() { + *x = GetConversationRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConversationRequest) ProtoMessage() {} + +func (x *GetConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetConversationRequest.ProtoReflect.Descriptor instead. +func (*GetConversationRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{7} +} + +func (x *GetConversationRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +// ListConversationsRequest lists conversations for the current user. +type ListConversationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"` + PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + Search string `protobuf:"bytes,3,opt,name=search,proto3" json:"search,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListConversationsRequest) Reset() { + *x = ListConversationsRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListConversationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListConversationsRequest) ProtoMessage() {} + +func (x *ListConversationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListConversationsRequest.ProtoReflect.Descriptor instead. +func (*ListConversationsRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{8} +} + +func (x *ListConversationsRequest) GetPage() int32 { + if x != nil { + return x.Page + } + return 0 +} + +func (x *ListConversationsRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListConversationsRequest) GetSearch() string { + if x != nil { + return x.Search + } + return "" +} + +// UpdateGroupConversationRequest updates a group conversation. +type UpdateGroupConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + AvatarUrl string `protobuf:"bytes,3,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateGroupConversationRequest) Reset() { + *x = UpdateGroupConversationRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateGroupConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateGroupConversationRequest) ProtoMessage() {} + +func (x *UpdateGroupConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateGroupConversationRequest.ProtoReflect.Descriptor instead. +func (*UpdateGroupConversationRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{9} +} + +func (x *UpdateGroupConversationRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *UpdateGroupConversationRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UpdateGroupConversationRequest) GetAvatarUrl() string { + if x != nil { + return x.AvatarUrl + } + return "" +} + +// AddParticipantsRequest adds users to a group conversation. +type AddParticipantsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + UserIds []string `protobuf:"bytes,2,rep,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddParticipantsRequest) Reset() { + *x = AddParticipantsRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddParticipantsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddParticipantsRequest) ProtoMessage() {} + +func (x *AddParticipantsRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddParticipantsRequest.ProtoReflect.Descriptor instead. +func (*AddParticipantsRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{10} +} + +func (x *AddParticipantsRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *AddParticipantsRequest) GetUserIds() []string { + if x != nil { + return x.UserIds + } + return nil +} + +// RemoveParticipantRequest removes a user from a group conversation. +type RemoveParticipantRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveParticipantRequest) Reset() { + *x = RemoveParticipantRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveParticipantRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveParticipantRequest) ProtoMessage() {} + +func (x *RemoveParticipantRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveParticipantRequest.ProtoReflect.Descriptor instead. +func (*RemoveParticipantRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{11} +} + +func (x *RemoveParticipantRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *RemoveParticipantRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +// LeaveConversationRequest allows the current user to leave a group conversation. +type LeaveConversationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LeaveConversationRequest) Reset() { + *x = LeaveConversationRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LeaveConversationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LeaveConversationRequest) ProtoMessage() {} + +func (x *LeaveConversationRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LeaveConversationRequest.ProtoReflect.Descriptor instead. +func (*LeaveConversationRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{12} +} + +func (x *LeaveConversationRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +// SendMessageRequest sends a message to a conversation. +type SendMessageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + // ID of the message being replied to (optional, empty = no reply). + ReplyToId string `protobuf:"bytes,3,opt,name=reply_to_id,json=replyToId,proto3" json:"reply_to_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendMessageRequest) Reset() { + *x = SendMessageRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendMessageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendMessageRequest) ProtoMessage() {} + +func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendMessageRequest.ProtoReflect.Descriptor instead. +func (*SendMessageRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{13} +} + +func (x *SendMessageRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *SendMessageRequest) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +func (x *SendMessageRequest) GetReplyToId() string { + if x != nil { + return x.ReplyToId + } + return "" +} + +// EditMessageRequest edits an existing message. +type EditMessageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + MessageId string `protobuf:"bytes,2,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditMessageRequest) Reset() { + *x = EditMessageRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditMessageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditMessageRequest) ProtoMessage() {} + +func (x *EditMessageRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EditMessageRequest.ProtoReflect.Descriptor instead. +func (*EditMessageRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{14} +} + +func (x *EditMessageRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *EditMessageRequest) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +func (x *EditMessageRequest) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +// DeleteMessageRequest soft-deletes a message. +type DeleteMessageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + MessageId string `protobuf:"bytes,2,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteMessageRequest) Reset() { + *x = DeleteMessageRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteMessageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteMessageRequest) ProtoMessage() {} + +func (x *DeleteMessageRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteMessageRequest.ProtoReflect.Descriptor instead. +func (*DeleteMessageRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{15} +} + +func (x *DeleteMessageRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *DeleteMessageRequest) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +// ListMessagesRequest lists messages in a conversation with cursor-based pagination. +type ListMessagesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Opaque base64 cursor; empty = start from latest messages. + BeforeCursor string `protobuf:"bytes,3,opt,name=before_cursor,json=beforeCursor,proto3" json:"before_cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMessagesRequest) Reset() { + *x = ListMessagesRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMessagesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMessagesRequest) ProtoMessage() {} + +func (x *ListMessagesRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMessagesRequest.ProtoReflect.Descriptor instead. +func (*ListMessagesRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{16} +} + +func (x *ListMessagesRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ListMessagesRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListMessagesRequest) GetBeforeCursor() string { + if x != nil { + return x.BeforeCursor + } + return "" +} + +// StreamChatEventsRequest opens a server-streaming connection for chat events. +type StreamChatEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Last event ID seen by the client; used to resume the stream after reconnect. + LastEventId string `protobuf:"bytes,1,opt,name=last_event_id,json=lastEventId,proto3" json:"last_event_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamChatEventsRequest) Reset() { + *x = StreamChatEventsRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamChatEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamChatEventsRequest) ProtoMessage() {} + +func (x *StreamChatEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamChatEventsRequest.ProtoReflect.Descriptor instead. +func (*StreamChatEventsRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{17} +} + +func (x *StreamChatEventsRequest) GetLastEventId() string { + if x != nil { + return x.LastEventId + } + return "" +} + +// MarkConversationReadRequest marks a conversation as read. +type MarkConversationReadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MarkConversationReadRequest) Reset() { + *x = MarkConversationReadRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MarkConversationReadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MarkConversationReadRequest) ProtoMessage() {} + +func (x *MarkConversationReadRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MarkConversationReadRequest.ProtoReflect.Descriptor instead. +func (*MarkConversationReadRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{18} +} + +func (x *MarkConversationReadRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +// SetTypingRequest sets the typing indicator for the current user. +type SetTypingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + IsTyping bool `protobuf:"varint,2,opt,name=is_typing,json=isTyping,proto3" json:"is_typing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetTypingRequest) Reset() { + *x = SetTypingRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetTypingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetTypingRequest) ProtoMessage() {} + +func (x *SetTypingRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetTypingRequest.ProtoReflect.Descriptor instead. +func (*SetTypingRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{19} +} + +func (x *SetTypingRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *SetTypingRequest) GetIsTyping() bool { + if x != nil { + return x.IsTyping + } + return false +} + +// GetMessageEditHistoryRequest retrieves the edit history for a message. +type GetMessageEditHistoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + MessageId string `protobuf:"bytes,2,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMessageEditHistoryRequest) Reset() { + *x = GetMessageEditHistoryRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMessageEditHistoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMessageEditHistoryRequest) ProtoMessage() {} + +func (x *GetMessageEditHistoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMessageEditHistoryRequest.ProtoReflect.Descriptor instead. +func (*GetMessageEditHistoryRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{20} +} + +func (x *GetMessageEditHistoryRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *GetMessageEditHistoryRequest) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +// HeartbeatRequest sends a heartbeat to update online presence. +type HeartbeatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeartbeatRequest) Reset() { + *x = HeartbeatRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatRequest) ProtoMessage() {} + +func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatRequest.ProtoReflect.Descriptor instead. +func (*HeartbeatRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{21} +} + +// GetOnlineUsersRequest retrieves online user IDs. +type GetOnlineUsersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserIds []string `protobuf:"bytes,1,rep,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetOnlineUsersRequest) Reset() { + *x = GetOnlineUsersRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetOnlineUsersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOnlineUsersRequest) ProtoMessage() {} + +func (x *GetOnlineUsersRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOnlineUsersRequest.ProtoReflect.Descriptor instead. +func (*GetOnlineUsersRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{22} +} + +func (x *GetOnlineUsersRequest) GetUserIds() []string { + if x != nil { + return x.UserIds + } + return nil +} + +// CreateDirectConversationResponse returns the created direct conversation. +type CreateDirectConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data *ConversationProto `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateDirectConversationResponse) Reset() { + *x = CreateDirectConversationResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateDirectConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateDirectConversationResponse) ProtoMessage() {} + +func (x *CreateDirectConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateDirectConversationResponse.ProtoReflect.Descriptor instead. +func (*CreateDirectConversationResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{23} +} + +func (x *CreateDirectConversationResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *CreateDirectConversationResponse) GetData() *ConversationProto { + if x != nil { + return x.Data + } + return nil +} + +// CreateGroupConversationResponse returns the created group conversation. +type CreateGroupConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data *ConversationProto `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateGroupConversationResponse) Reset() { + *x = CreateGroupConversationResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateGroupConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateGroupConversationResponse) ProtoMessage() {} + +func (x *CreateGroupConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateGroupConversationResponse.ProtoReflect.Descriptor instead. +func (*CreateGroupConversationResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{24} +} + +func (x *CreateGroupConversationResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *CreateGroupConversationResponse) GetData() *ConversationProto { + if x != nil { + return x.Data + } + return nil +} + +// GetConversationResponse returns a single conversation. +type GetConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data *ConversationProto `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConversationResponse) Reset() { + *x = GetConversationResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConversationResponse) ProtoMessage() {} + +func (x *GetConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetConversationResponse.ProtoReflect.Descriptor instead. +func (*GetConversationResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{25} +} + +func (x *GetConversationResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *GetConversationResponse) GetData() *ConversationProto { + if x != nil { + return x.Data + } + return nil +} + +// ListConversationsResponse returns a paginated list of conversations. +type ListConversationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data []*ConversationProto `protobuf:"bytes,2,rep,name=data,proto3" json:"data,omitempty"` + Pagination *v1.PaginationResponse `protobuf:"bytes,3,opt,name=pagination,proto3" json:"pagination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListConversationsResponse) Reset() { + *x = ListConversationsResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListConversationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListConversationsResponse) ProtoMessage() {} + +func (x *ListConversationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListConversationsResponse.ProtoReflect.Descriptor instead. +func (*ListConversationsResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{26} +} + +func (x *ListConversationsResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *ListConversationsResponse) GetData() []*ConversationProto { + if x != nil { + return x.Data + } + return nil +} + +func (x *ListConversationsResponse) GetPagination() *v1.PaginationResponse { + if x != nil { + return x.Pagination + } + return nil +} + +// UpdateGroupConversationResponse returns the updated group conversation. +type UpdateGroupConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data *ConversationProto `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateGroupConversationResponse) Reset() { + *x = UpdateGroupConversationResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateGroupConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateGroupConversationResponse) ProtoMessage() {} + +func (x *UpdateGroupConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateGroupConversationResponse.ProtoReflect.Descriptor instead. +func (*UpdateGroupConversationResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{27} +} + +func (x *UpdateGroupConversationResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *UpdateGroupConversationResponse) GetData() *ConversationProto { + if x != nil { + return x.Data + } + return nil +} + +// AddParticipantsResponse confirms participants were added. +type AddParticipantsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddParticipantsResponse) Reset() { + *x = AddParticipantsResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddParticipantsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddParticipantsResponse) ProtoMessage() {} + +func (x *AddParticipantsResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddParticipantsResponse.ProtoReflect.Descriptor instead. +func (*AddParticipantsResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{28} +} + +func (x *AddParticipantsResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +// RemoveParticipantResponse confirms a participant was removed. +type RemoveParticipantResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveParticipantResponse) Reset() { + *x = RemoveParticipantResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveParticipantResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveParticipantResponse) ProtoMessage() {} + +func (x *RemoveParticipantResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveParticipantResponse.ProtoReflect.Descriptor instead. +func (*RemoveParticipantResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{29} +} + +func (x *RemoveParticipantResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +// LeaveConversationResponse confirms the user left the conversation. +type LeaveConversationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LeaveConversationResponse) Reset() { + *x = LeaveConversationResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LeaveConversationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LeaveConversationResponse) ProtoMessage() {} + +func (x *LeaveConversationResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LeaveConversationResponse.ProtoReflect.Descriptor instead. +func (*LeaveConversationResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{30} +} + +func (x *LeaveConversationResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +// SendMessageResponse returns the sent message. +type SendMessageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data *MessageProto `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendMessageResponse) Reset() { + *x = SendMessageResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendMessageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendMessageResponse) ProtoMessage() {} + +func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendMessageResponse.ProtoReflect.Descriptor instead. +func (*SendMessageResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{31} +} + +func (x *SendMessageResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *SendMessageResponse) GetData() *MessageProto { + if x != nil { + return x.Data + } + return nil +} + +// EditMessageResponse returns the edited message. +type EditMessageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data *MessageProto `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditMessageResponse) Reset() { + *x = EditMessageResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditMessageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditMessageResponse) ProtoMessage() {} + +func (x *EditMessageResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EditMessageResponse.ProtoReflect.Descriptor instead. +func (*EditMessageResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{32} +} + +func (x *EditMessageResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *EditMessageResponse) GetData() *MessageProto { + if x != nil { + return x.Data + } + return nil +} + +// DeleteMessageResponse confirms a message was deleted. +type DeleteMessageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteMessageResponse) Reset() { + *x = DeleteMessageResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteMessageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteMessageResponse) ProtoMessage() {} + +func (x *DeleteMessageResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteMessageResponse.ProtoReflect.Descriptor instead. +func (*DeleteMessageResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{33} +} + +func (x *DeleteMessageResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +// ListMessagesResponse returns a cursor-paginated list of messages. +type ListMessagesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data []*MessageProto `protobuf:"bytes,2,rep,name=data,proto3" json:"data,omitempty"` + NextCursor string `protobuf:"bytes,3,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + HasMore bool `protobuf:"varint,4,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMessagesResponse) Reset() { + *x = ListMessagesResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMessagesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMessagesResponse) ProtoMessage() {} + +func (x *ListMessagesResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMessagesResponse.ProtoReflect.Descriptor instead. +func (*ListMessagesResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{34} +} + +func (x *ListMessagesResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *ListMessagesResponse) GetData() []*MessageProto { + if x != nil { + return x.Data + } + return nil +} + +func (x *ListMessagesResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +func (x *ListMessagesResponse) GetHasMore() bool { + if x != nil { + return x.HasMore + } + return false +} + +// MarkConversationReadResponse confirms the conversation was marked as read. +type MarkConversationReadResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MarkConversationReadResponse) Reset() { + *x = MarkConversationReadResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MarkConversationReadResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MarkConversationReadResponse) ProtoMessage() {} + +func (x *MarkConversationReadResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MarkConversationReadResponse.ProtoReflect.Descriptor instead. +func (*MarkConversationReadResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{35} +} + +func (x *MarkConversationReadResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +// SetTypingResponse confirms the typing indicator was set. +type SetTypingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetTypingResponse) Reset() { + *x = SetTypingResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetTypingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetTypingResponse) ProtoMessage() {} + +func (x *SetTypingResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetTypingResponse.ProtoReflect.Descriptor instead. +func (*SetTypingResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{36} +} + +func (x *SetTypingResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +// GetMessageEditHistoryResponse returns the edit history for a message. +type GetMessageEditHistoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data []*EditHistoryEntryProto `protobuf:"bytes,2,rep,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMessageEditHistoryResponse) Reset() { + *x = GetMessageEditHistoryResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMessageEditHistoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMessageEditHistoryResponse) ProtoMessage() {} + +func (x *GetMessageEditHistoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMessageEditHistoryResponse.ProtoReflect.Descriptor instead. +func (*GetMessageEditHistoryResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{37} +} + +func (x *GetMessageEditHistoryResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *GetMessageEditHistoryResponse) GetData() []*EditHistoryEntryProto { + if x != nil { + return x.Data + } + return nil +} + +// HeartbeatResponse confirms the heartbeat was received. +type HeartbeatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeartbeatResponse) Reset() { + *x = HeartbeatResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatResponse) ProtoMessage() {} + +func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatResponse.ProtoReflect.Descriptor instead. +func (*HeartbeatResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{38} +} + +func (x *HeartbeatResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +// GetOnlineUsersResponse returns the list of online user IDs. +type GetOnlineUsersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + UserIds []string `protobuf:"bytes,2,rep,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetOnlineUsersResponse) Reset() { + *x = GetOnlineUsersResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetOnlineUsersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOnlineUsersResponse) ProtoMessage() {} + +func (x *GetOnlineUsersResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOnlineUsersResponse.ProtoReflect.Descriptor instead. +func (*GetOnlineUsersResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{39} +} + +func (x *GetOnlineUsersResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *GetOnlineUsersResponse) GetUserIds() []string { + if x != nil { + return x.UserIds + } + return nil +} + +// StreamChatEventsResponse wraps a real-time chat event delivered via server streaming. +type StreamChatEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *StreamChatEventsResponse_MessageReceived + // *StreamChatEventsResponse_MessageEdited + // *StreamChatEventsResponse_MessageDeleted + // *StreamChatEventsResponse_Typing + // *StreamChatEventsResponse_ReadReceipt + // *StreamChatEventsResponse_Presence + Payload isStreamChatEventsResponse_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamChatEventsResponse) Reset() { + *x = StreamChatEventsResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamChatEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamChatEventsResponse) ProtoMessage() {} + +func (x *StreamChatEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamChatEventsResponse.ProtoReflect.Descriptor instead. +func (*StreamChatEventsResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{40} +} + +func (x *StreamChatEventsResponse) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +func (x *StreamChatEventsResponse) GetPayload() isStreamChatEventsResponse_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *StreamChatEventsResponse) GetMessageReceived() *MessageEvent { + if x != nil { + if x, ok := x.Payload.(*StreamChatEventsResponse_MessageReceived); ok { + return x.MessageReceived + } + } + return nil +} + +func (x *StreamChatEventsResponse) GetMessageEdited() *MessageEvent { + if x != nil { + if x, ok := x.Payload.(*StreamChatEventsResponse_MessageEdited); ok { + return x.MessageEdited + } + } + return nil +} + +func (x *StreamChatEventsResponse) GetMessageDeleted() *DeleteEvent { + if x != nil { + if x, ok := x.Payload.(*StreamChatEventsResponse_MessageDeleted); ok { + return x.MessageDeleted + } + } + return nil +} + +func (x *StreamChatEventsResponse) GetTyping() *TypingEvent { + if x != nil { + if x, ok := x.Payload.(*StreamChatEventsResponse_Typing); ok { + return x.Typing + } + } + return nil +} + +func (x *StreamChatEventsResponse) GetReadReceipt() *ReadEvent { + if x != nil { + if x, ok := x.Payload.(*StreamChatEventsResponse_ReadReceipt); ok { + return x.ReadReceipt + } + } + return nil +} + +func (x *StreamChatEventsResponse) GetPresence() *PresenceEvent { + if x != nil { + if x, ok := x.Payload.(*StreamChatEventsResponse_Presence); ok { + return x.Presence + } + } + return nil +} + +type isStreamChatEventsResponse_Payload interface { + isStreamChatEventsResponse_Payload() +} + +type StreamChatEventsResponse_MessageReceived struct { + MessageReceived *MessageEvent `protobuf:"bytes,2,opt,name=message_received,json=messageReceived,proto3,oneof"` +} + +type StreamChatEventsResponse_MessageEdited struct { + MessageEdited *MessageEvent `protobuf:"bytes,3,opt,name=message_edited,json=messageEdited,proto3,oneof"` +} + +type StreamChatEventsResponse_MessageDeleted struct { + MessageDeleted *DeleteEvent `protobuf:"bytes,4,opt,name=message_deleted,json=messageDeleted,proto3,oneof"` +} + +type StreamChatEventsResponse_Typing struct { + Typing *TypingEvent `protobuf:"bytes,5,opt,name=typing,proto3,oneof"` +} + +type StreamChatEventsResponse_ReadReceipt struct { + ReadReceipt *ReadEvent `protobuf:"bytes,6,opt,name=read_receipt,json=readReceipt,proto3,oneof"` +} + +type StreamChatEventsResponse_Presence struct { + Presence *PresenceEvent `protobuf:"bytes,7,opt,name=presence,proto3,oneof"` +} + +func (*StreamChatEventsResponse_MessageReceived) isStreamChatEventsResponse_Payload() {} + +func (*StreamChatEventsResponse_MessageEdited) isStreamChatEventsResponse_Payload() {} + +func (*StreamChatEventsResponse_MessageDeleted) isStreamChatEventsResponse_Payload() {} + +func (*StreamChatEventsResponse_Typing) isStreamChatEventsResponse_Payload() {} + +func (*StreamChatEventsResponse_ReadReceipt) isStreamChatEventsResponse_Payload() {} + +func (*StreamChatEventsResponse_Presence) isStreamChatEventsResponse_Payload() {} + +// MessageEvent represents a new or edited message event. +type MessageEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + Message *MessageProto `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MessageEvent) Reset() { + *x = MessageEvent{} + mi := &file_iam_v1_chat_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MessageEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MessageEvent) ProtoMessage() {} + +func (x *MessageEvent) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MessageEvent.ProtoReflect.Descriptor instead. +func (*MessageEvent) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{41} +} + +func (x *MessageEvent) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *MessageEvent) GetMessage() *MessageProto { + if x != nil { + return x.Message + } + return nil +} + +// DeleteEvent represents a message deletion event. +type DeleteEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + MessageId string `protobuf:"bytes,2,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteEvent) Reset() { + *x = DeleteEvent{} + mi := &file_iam_v1_chat_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteEvent) ProtoMessage() {} + +func (x *DeleteEvent) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteEvent.ProtoReflect.Descriptor instead. +func (*DeleteEvent) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{42} +} + +func (x *DeleteEvent) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *DeleteEvent) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +// TypingEvent represents a typing indicator event. +type TypingEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + UserName string `protobuf:"bytes,3,opt,name=user_name,json=userName,proto3" json:"user_name,omitempty"` + IsTyping bool `protobuf:"varint,4,opt,name=is_typing,json=isTyping,proto3" json:"is_typing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TypingEvent) Reset() { + *x = TypingEvent{} + mi := &file_iam_v1_chat_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TypingEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TypingEvent) ProtoMessage() {} + +func (x *TypingEvent) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TypingEvent.ProtoReflect.Descriptor instead. +func (*TypingEvent) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{43} +} + +func (x *TypingEvent) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *TypingEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *TypingEvent) GetUserName() string { + if x != nil { + return x.UserName + } + return "" +} + +func (x *TypingEvent) GetIsTyping() bool { + if x != nil { + return x.IsTyping + } + return false +} + +// ReadEvent represents a read receipt event. +type ReadEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + MessageId string `protobuf:"bytes,2,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + UserId string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ReadAt string `protobuf:"bytes,4,opt,name=read_at,json=readAt,proto3" json:"read_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadEvent) Reset() { + *x = ReadEvent{} + mi := &file_iam_v1_chat_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadEvent) ProtoMessage() {} + +func (x *ReadEvent) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadEvent.ProtoReflect.Descriptor instead. +func (*ReadEvent) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{44} +} + +func (x *ReadEvent) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ReadEvent) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +func (x *ReadEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ReadEvent) GetReadAt() string { + if x != nil { + return x.ReadAt + } + return "" +} + +// PresenceEvent represents a user online/offline status change. +type PresenceEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + IsOnline bool `protobuf:"varint,2,opt,name=is_online,json=isOnline,proto3" json:"is_online,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PresenceEvent) Reset() { + *x = PresenceEvent{} + mi := &file_iam_v1_chat_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PresenceEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PresenceEvent) ProtoMessage() {} + +func (x *PresenceEvent) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PresenceEvent.ProtoReflect.Descriptor instead. +func (*PresenceEvent) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{45} +} + +func (x *PresenceEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *PresenceEvent) GetIsOnline() bool { + if x != nil { + return x.IsOnline + } + return false +} + +var File_iam_v1_chat_proto protoreflect.FileDescriptor + +const file_iam_v1_chat_proto_rawDesc = "" + + "\n" + + "\x11iam/v1/chat.proto\x12\x06iam.v1\x1a\x1bbuf/validate/validate.proto\x1a\x16common/v1/common.proto\x1a\x1cgoogle/api/annotations.proto\"\xdb\x02\n" + + "\x11ConversationProto\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "avatar_url\x18\x04 \x01(\tR\tavatarUrl\x12<\n" + + "\fparticipants\x18\x05 \x03(\v2\x18.iam.v1.ParticipantProtoR\fparticipants\x127\n" + + "\flast_message\x18\x06 \x01(\v2\x14.iam.v1.MessageProtoR\vlastMessage\x12!\n" + + "\funread_count\x18\a \x01(\x05R\vunreadCount\x12\x1d\n" + + "\n" + + "created_at\x18\b \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\t \x01(\tR\tupdatedAt\"\xd1\x01\n" + + "\x10ParticipantProto\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x1a\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x1b\n" + + "\tfull_name\x18\x03 \x01(\tR\bfullName\x12\x1d\n" + + "\n" + + "avatar_url\x18\x04 \x01(\tR\tavatarUrl\x12\x12\n" + + "\x04role\x18\x05 \x01(\tR\x04role\x12\x1b\n" + + "\tis_online\x18\x06 \x01(\bR\bisOnline\x12\x1b\n" + + "\tjoined_at\x18\a \x01(\tR\bjoinedAt\"\x8a\x03\n" + + "\fMessageProto\x12\x1d\n" + + "\n" + + "message_id\x18\x01 \x01(\tR\tmessageId\x12'\n" + + "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12$\n" + + "\x0esender_user_id\x18\x03 \x01(\tR\fsenderUserId\x12\x1f\n" + + "\vsender_name\x18\x04 \x01(\tR\n" + + "senderName\x12\x12\n" + + "\x04body\x18\x05 \x01(\tR\x04body\x12\x1b\n" + + "\tis_edited\x18\x06 \x01(\bR\bisEdited\x12\x1d\n" + + "\n" + + "is_deleted\x18\a \x01(\bR\tisDeleted\x12\x1e\n" + + "\vreply_to_id\x18\b \x01(\tR\treplyToId\x12=\n" + + "\rread_receipts\x18\t \x03(\v2\x18.iam.v1.ReadReceiptProtoR\freadReceipts\x12\x1d\n" + + "\n" + + "created_at\x18\n" + + " \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\v \x01(\tR\tupdatedAt\"D\n" + + "\x10ReadReceiptProto\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x17\n" + + "\aread_at\x18\x02 \x01(\tR\x06readAt\"\x84\x01\n" + + "\x15EditHistoryEntryProto\x12\x1d\n" + + "\n" + + "history_id\x18\x01 \x01(\x03R\thistoryId\x12\x12\n" + + "\x04body\x18\x02 \x01(\tR\x04body\x12\x1b\n" + + "\tedited_by\x18\x03 \x01(\tR\beditedBy\x12\x1b\n" + + "\tedited_at\x18\x04 \x01(\tR\beditedAt\"M\n" + + "\x1fCreateDirectConversationRequest\x12*\n" + + "\fpeer_user_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\n" + + "peerUserId\"s\n" + + "\x1eCreateGroupConversationRequest\x12\x1e\n" + + "\x04name\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xc8\x01R\x04name\x121\n" + + "\x0fparticipant_ids\x18\x02 \x03(\tB\b\xbaH\x05\x92\x01\x02\b\x01R\x0eparticipantIds\"K\n" + + "\x16GetConversationRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\"\x80\x01\n" + + "\x18ListConversationsRequest\x12\x1b\n" + + "\x04page\x18\x01 \x01(\x05B\a\xbaH\x04\x1a\x02(\x01R\x04page\x12&\n" + + "\tpage_size\x18\x02 \x01(\x05B\t\xbaH\x06\x1a\x04\x18d(\x01R\bpageSize\x12\x1f\n" + + "\x06search\x18\x03 \x01(\tB\a\xbaH\x04r\x02\x18dR\x06search\"\x9a\x01\n" + + "\x1eUpdateGroupConversationRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12\x1c\n" + + "\x04name\x18\x02 \x01(\tB\b\xbaH\x05r\x03\x18\xc8\x01R\x04name\x12'\n" + + "\n" + + "avatar_url\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x10R\tavatarUrl\"p\n" + + "\x16AddParticipantsRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12#\n" + + "\buser_ids\x18\x02 \x03(\tB\b\xbaH\x05\x92\x01\x02\b\x01R\auserIds\"p\n" + + "\x18RemoveParticipantRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12!\n" + + "\auser_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\"M\n" + + "\x18LeaveConversationRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\"\x87\x01\n" + + "\x12SendMessageRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12\x1e\n" + + "\x04body\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x90NR\x04body\x12\x1e\n" + + "\vreply_to_id\x18\x03 \x01(\tR\treplyToId\"\x90\x01\n" + + "\x12EditMessageRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12'\n" + + "\n" + + "message_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tmessageId\x12\x1e\n" + + "\x04body\x18\x03 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x90NR\x04body\"r\n" + + "\x14DeleteMessageRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12'\n" + + "\n" + + "message_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tmessageId\"\x95\x01\n" + + "\x13ListMessagesRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12&\n" + + "\tpage_size\x18\x02 \x01(\x05B\t\xbaH\x06\x1a\x04\x18d(\x01R\bpageSize\x12#\n" + + "\rbefore_cursor\x18\x03 \x01(\tR\fbeforeCursor\"=\n" + + "\x17StreamChatEventsRequest\x12\"\n" + + "\rlast_event_id\x18\x01 \x01(\tR\vlastEventId\"P\n" + + "\x1bMarkConversationReadRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\"b\n" + + "\x10SetTypingRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12\x1b\n" + + "\tis_typing\x18\x02 \x01(\bR\bisTyping\"z\n" + + "\x1cGetMessageEditHistoryRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12'\n" + + "\n" + + "message_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tmessageId\"\x12\n" + + "\x10HeartbeatRequest\"2\n" + + "\x15GetOnlineUsersRequest\x12\x19\n" + + "\buser_ids\x18\x01 \x03(\tR\auserIds\"~\n" + + " CreateDirectConversationResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12-\n" + + "\x04data\x18\x02 \x01(\v2\x19.iam.v1.ConversationProtoR\x04data\"}\n" + + "\x1fCreateGroupConversationResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12-\n" + + "\x04data\x18\x02 \x01(\v2\x19.iam.v1.ConversationProtoR\x04data\"u\n" + + "\x17GetConversationResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12-\n" + + "\x04data\x18\x02 \x01(\v2\x19.iam.v1.ConversationProtoR\x04data\"\xb6\x01\n" + + "\x19ListConversationsResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12-\n" + + "\x04data\x18\x02 \x03(\v2\x19.iam.v1.ConversationProtoR\x04data\x12=\n" + + "\n" + + "pagination\x18\x03 \x01(\v2\x1d.common.v1.PaginationResponseR\n" + + "pagination\"}\n" + + "\x1fUpdateGroupConversationResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12-\n" + + "\x04data\x18\x02 \x01(\v2\x19.iam.v1.ConversationProtoR\x04data\"F\n" + + "\x17AddParticipantsResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"H\n" + + "\x19RemoveParticipantResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"H\n" + + "\x19LeaveConversationResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"l\n" + + "\x13SendMessageResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12(\n" + + "\x04data\x18\x02 \x01(\v2\x14.iam.v1.MessageProtoR\x04data\"l\n" + + "\x13EditMessageResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12(\n" + + "\x04data\x18\x02 \x01(\v2\x14.iam.v1.MessageProtoR\x04data\"D\n" + + "\x15DeleteMessageResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"\xa9\x01\n" + + "\x14ListMessagesResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12(\n" + + "\x04data\x18\x02 \x03(\v2\x14.iam.v1.MessageProtoR\x04data\x12\x1f\n" + + "\vnext_cursor\x18\x03 \x01(\tR\n" + + "nextCursor\x12\x19\n" + + "\bhas_more\x18\x04 \x01(\bR\ahasMore\"K\n" + + "\x1cMarkConversationReadResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"@\n" + + "\x11SetTypingResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"\x7f\n" + + "\x1dGetMessageEditHistoryResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x121\n" + + "\x04data\x18\x02 \x03(\v2\x1d.iam.v1.EditHistoryEntryProtoR\x04data\"@\n" + + "\x11HeartbeatResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"`\n" + + "\x16GetOnlineUsersResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12\x19\n" + + "\buser_ids\x18\x02 \x03(\tR\auserIds\"\x9e\x03\n" + + "\x18StreamChatEventsResponse\x12\x19\n" + + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12A\n" + + "\x10message_received\x18\x02 \x01(\v2\x14.iam.v1.MessageEventH\x00R\x0fmessageReceived\x12=\n" + + "\x0emessage_edited\x18\x03 \x01(\v2\x14.iam.v1.MessageEventH\x00R\rmessageEdited\x12>\n" + + "\x0fmessage_deleted\x18\x04 \x01(\v2\x13.iam.v1.DeleteEventH\x00R\x0emessageDeleted\x12-\n" + + "\x06typing\x18\x05 \x01(\v2\x13.iam.v1.TypingEventH\x00R\x06typing\x126\n" + + "\fread_receipt\x18\x06 \x01(\v2\x11.iam.v1.ReadEventH\x00R\vreadReceipt\x123\n" + + "\bpresence\x18\a \x01(\v2\x15.iam.v1.PresenceEventH\x00R\bpresenceB\t\n" + + "\apayload\"g\n" + + "\fMessageEvent\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12.\n" + + "\amessage\x18\x02 \x01(\v2\x14.iam.v1.MessageProtoR\amessage\"U\n" + + "\vDeleteEvent\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x1d\n" + + "\n" + + "message_id\x18\x02 \x01(\tR\tmessageId\"\x89\x01\n" + + "\vTypingEvent\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x17\n" + + "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x1b\n" + + "\tuser_name\x18\x03 \x01(\tR\buserName\x12\x1b\n" + + "\tis_typing\x18\x04 \x01(\bR\bisTyping\"\x85\x01\n" + + "\tReadEvent\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x1d\n" + + "\n" + + "message_id\x18\x02 \x01(\tR\tmessageId\x12\x17\n" + + "\auser_id\x18\x03 \x01(\tR\x06userId\x12\x17\n" + + "\aread_at\x18\x04 \x01(\tR\x06readAt\"E\n" + + "\rPresenceEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x1b\n" + + "\tis_online\x18\x02 \x01(\bR\bisOnline2\xb1\x13\n" + + "\vChatService\x12\x9f\x01\n" + + "\x18CreateDirectConversation\x12'.iam.v1.CreateDirectConversationRequest\x1a(.iam.v1.CreateDirectConversationResponse\"0\x82\xd3\xe4\x93\x02*:\x01*\"%/api/v1/iam/chat/conversations/direct\x12\x9b\x01\n" + + "\x17CreateGroupConversation\x12&.iam.v1.CreateGroupConversationRequest\x1a'.iam.v1.CreateGroupConversationResponse\"/\x82\xd3\xe4\x93\x02):\x01*\"$/api/v1/iam/chat/conversations/group\x12\x8c\x01\n" + + "\x0fGetConversation\x12\x1e.iam.v1.GetConversationRequest\x1a\x1f.iam.v1.GetConversationResponse\"8\x82\xd3\xe4\x93\x022\x120/api/v1/iam/chat/conversations/{conversation_id}\x12\x80\x01\n" + + "\x11ListConversations\x12 .iam.v1.ListConversationsRequest\x1a!.iam.v1.ListConversationsResponse\"&\x82\xd3\xe4\x93\x02 \x12\x1e/api/v1/iam/chat/conversations\x12\xa7\x01\n" + + "\x17UpdateGroupConversation\x12&.iam.v1.UpdateGroupConversationRequest\x1a'.iam.v1.UpdateGroupConversationResponse\";\x82\xd3\xe4\x93\x025:\x01*\x1a0/api/v1/iam/chat/conversations/{conversation_id}\x12\x9c\x01\n" + + "\x0fAddParticipants\x12\x1e.iam.v1.AddParticipantsRequest\x1a\x1f.iam.v1.AddParticipantsResponse\"H\x82\xd3\xe4\x93\x02B:\x01*\"=/api/v1/iam/chat/conversations/{conversation_id}/participants\x12\xa9\x01\n" + + "\x11RemoveParticipant\x12 .iam.v1.RemoveParticipantRequest\x1a!.iam.v1.RemoveParticipantResponse\"O\x82\xd3\xe4\x93\x02I*G/api/v1/iam/chat/conversations/{conversation_id}/participants/{user_id}\x12\x9b\x01\n" + + "\x11LeaveConversation\x12 .iam.v1.LeaveConversationRequest\x1a!.iam.v1.LeaveConversationResponse\"A\x82\xd3\xe4\x93\x02;:\x01*\"6/api/v1/iam/chat/conversations/{conversation_id}/leave\x12\x8c\x01\n" + + "\vSendMessage\x12\x1a.iam.v1.SendMessageRequest\x1a\x1b.iam.v1.SendMessageResponse\"D\x82\xd3\xe4\x93\x02>:\x01*\"9/api/v1/iam/chat/conversations/{conversation_id}/messages\x12\x99\x01\n" + + "\vEditMessage\x12\x1a.iam.v1.EditMessageRequest\x1a\x1b.iam.v1.EditMessageResponse\"Q\x82\xd3\xe4\x93\x02K:\x01*\x1aF/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}\x12\x9c\x01\n" + + "\rDeleteMessage\x12\x1c.iam.v1.DeleteMessageRequest\x1a\x1d.iam.v1.DeleteMessageResponse\"N\x82\xd3\xe4\x93\x02H*F/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}\x12\x8c\x01\n" + + "\fListMessages\x12\x1b.iam.v1.ListMessagesRequest\x1a\x1c.iam.v1.ListMessagesResponse\"A\x82\xd3\xe4\x93\x02;\x129/api/v1/iam/chat/conversations/{conversation_id}/messages\x12x\n" + + "\x10StreamChatEvents\x12\x1f.iam.v1.StreamChatEventsRequest\x1a .iam.v1.StreamChatEventsResponse\"\x1f\x82\xd3\xe4\x93\x02\x19\x12\x17/api/v1/iam/chat/events0\x01\x12\xa3\x01\n" + + "\x14MarkConversationRead\x12#.iam.v1.MarkConversationReadRequest\x1a$.iam.v1.MarkConversationReadResponse\"@\x82\xd3\xe4\x93\x02::\x01*\"5/api/v1/iam/chat/conversations/{conversation_id}/read\x12\x84\x01\n" + + "\tSetTyping\x12\x18.iam.v1.SetTypingRequest\x1a\x19.iam.v1.SetTypingResponse\"B\x82\xd3\xe4\x93\x02<:\x01*\"7/api/v1/iam/chat/conversations/{conversation_id}/typing\x12\xbc\x01\n" + + "\x15GetMessageEditHistory\x12$.iam.v1.GetMessageEditHistoryRequest\x1a%.iam.v1.GetMessageEditHistoryResponse\"V\x82\xd3\xe4\x93\x02P\x12N/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}/history2\xf4\x01\n" + + "\x0fPresenceService\x12k\n" + + "\tHeartbeat\x12\x18.iam.v1.HeartbeatRequest\x1a\x19.iam.v1.HeartbeatResponse\")\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/api/v1/iam/presence/heartbeat\x12t\n" + + "\x0eGetOnlineUsers\x12\x1d.iam.v1.GetOnlineUsersRequest\x1a\x1e.iam.v1.GetOnlineUsersResponse\"#\x82\xd3\xe4\x93\x02\x1d\x12\x1b/api/v1/iam/presence/onlineB\x87\x01\n" + + "\n" + + "com.iam.v1B\tChatProtoP\x01Z5github.com/mutugading/goapps-backend/gen/iam/v1;iamv1\xa2\x02\x03IXX\xaa\x02\x06Iam.V1\xca\x02\x06Iam\\V1\xe2\x02\x12Iam\\V1\\GPBMetadata\xea\x02\aIam::V1b\x06proto3" + +var ( + file_iam_v1_chat_proto_rawDescOnce sync.Once + file_iam_v1_chat_proto_rawDescData []byte +) + +func file_iam_v1_chat_proto_rawDescGZIP() []byte { + file_iam_v1_chat_proto_rawDescOnce.Do(func() { + file_iam_v1_chat_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_iam_v1_chat_proto_rawDesc), len(file_iam_v1_chat_proto_rawDesc))) + }) + return file_iam_v1_chat_proto_rawDescData +} + +var file_iam_v1_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 46) +var file_iam_v1_chat_proto_goTypes = []any{ + (*ConversationProto)(nil), // 0: iam.v1.ConversationProto + (*ParticipantProto)(nil), // 1: iam.v1.ParticipantProto + (*MessageProto)(nil), // 2: iam.v1.MessageProto + (*ReadReceiptProto)(nil), // 3: iam.v1.ReadReceiptProto + (*EditHistoryEntryProto)(nil), // 4: iam.v1.EditHistoryEntryProto + (*CreateDirectConversationRequest)(nil), // 5: iam.v1.CreateDirectConversationRequest + (*CreateGroupConversationRequest)(nil), // 6: iam.v1.CreateGroupConversationRequest + (*GetConversationRequest)(nil), // 7: iam.v1.GetConversationRequest + (*ListConversationsRequest)(nil), // 8: iam.v1.ListConversationsRequest + (*UpdateGroupConversationRequest)(nil), // 9: iam.v1.UpdateGroupConversationRequest + (*AddParticipantsRequest)(nil), // 10: iam.v1.AddParticipantsRequest + (*RemoveParticipantRequest)(nil), // 11: iam.v1.RemoveParticipantRequest + (*LeaveConversationRequest)(nil), // 12: iam.v1.LeaveConversationRequest + (*SendMessageRequest)(nil), // 13: iam.v1.SendMessageRequest + (*EditMessageRequest)(nil), // 14: iam.v1.EditMessageRequest + (*DeleteMessageRequest)(nil), // 15: iam.v1.DeleteMessageRequest + (*ListMessagesRequest)(nil), // 16: iam.v1.ListMessagesRequest + (*StreamChatEventsRequest)(nil), // 17: iam.v1.StreamChatEventsRequest + (*MarkConversationReadRequest)(nil), // 18: iam.v1.MarkConversationReadRequest + (*SetTypingRequest)(nil), // 19: iam.v1.SetTypingRequest + (*GetMessageEditHistoryRequest)(nil), // 20: iam.v1.GetMessageEditHistoryRequest + (*HeartbeatRequest)(nil), // 21: iam.v1.HeartbeatRequest + (*GetOnlineUsersRequest)(nil), // 22: iam.v1.GetOnlineUsersRequest + (*CreateDirectConversationResponse)(nil), // 23: iam.v1.CreateDirectConversationResponse + (*CreateGroupConversationResponse)(nil), // 24: iam.v1.CreateGroupConversationResponse + (*GetConversationResponse)(nil), // 25: iam.v1.GetConversationResponse + (*ListConversationsResponse)(nil), // 26: iam.v1.ListConversationsResponse + (*UpdateGroupConversationResponse)(nil), // 27: iam.v1.UpdateGroupConversationResponse + (*AddParticipantsResponse)(nil), // 28: iam.v1.AddParticipantsResponse + (*RemoveParticipantResponse)(nil), // 29: iam.v1.RemoveParticipantResponse + (*LeaveConversationResponse)(nil), // 30: iam.v1.LeaveConversationResponse + (*SendMessageResponse)(nil), // 31: iam.v1.SendMessageResponse + (*EditMessageResponse)(nil), // 32: iam.v1.EditMessageResponse + (*DeleteMessageResponse)(nil), // 33: iam.v1.DeleteMessageResponse + (*ListMessagesResponse)(nil), // 34: iam.v1.ListMessagesResponse + (*MarkConversationReadResponse)(nil), // 35: iam.v1.MarkConversationReadResponse + (*SetTypingResponse)(nil), // 36: iam.v1.SetTypingResponse + (*GetMessageEditHistoryResponse)(nil), // 37: iam.v1.GetMessageEditHistoryResponse + (*HeartbeatResponse)(nil), // 38: iam.v1.HeartbeatResponse + (*GetOnlineUsersResponse)(nil), // 39: iam.v1.GetOnlineUsersResponse + (*StreamChatEventsResponse)(nil), // 40: iam.v1.StreamChatEventsResponse + (*MessageEvent)(nil), // 41: iam.v1.MessageEvent + (*DeleteEvent)(nil), // 42: iam.v1.DeleteEvent + (*TypingEvent)(nil), // 43: iam.v1.TypingEvent + (*ReadEvent)(nil), // 44: iam.v1.ReadEvent + (*PresenceEvent)(nil), // 45: iam.v1.PresenceEvent + (*v1.BaseResponse)(nil), // 46: common.v1.BaseResponse + (*v1.PaginationResponse)(nil), // 47: common.v1.PaginationResponse +} +var file_iam_v1_chat_proto_depIdxs = []int32{ + 1, // 0: iam.v1.ConversationProto.participants:type_name -> iam.v1.ParticipantProto + 2, // 1: iam.v1.ConversationProto.last_message:type_name -> iam.v1.MessageProto + 3, // 2: iam.v1.MessageProto.read_receipts:type_name -> iam.v1.ReadReceiptProto + 46, // 3: iam.v1.CreateDirectConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 4: iam.v1.CreateDirectConversationResponse.data:type_name -> iam.v1.ConversationProto + 46, // 5: iam.v1.CreateGroupConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 6: iam.v1.CreateGroupConversationResponse.data:type_name -> iam.v1.ConversationProto + 46, // 7: iam.v1.GetConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 8: iam.v1.GetConversationResponse.data:type_name -> iam.v1.ConversationProto + 46, // 9: iam.v1.ListConversationsResponse.base:type_name -> common.v1.BaseResponse + 0, // 10: iam.v1.ListConversationsResponse.data:type_name -> iam.v1.ConversationProto + 47, // 11: iam.v1.ListConversationsResponse.pagination:type_name -> common.v1.PaginationResponse + 46, // 12: iam.v1.UpdateGroupConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 13: iam.v1.UpdateGroupConversationResponse.data:type_name -> iam.v1.ConversationProto + 46, // 14: iam.v1.AddParticipantsResponse.base:type_name -> common.v1.BaseResponse + 46, // 15: iam.v1.RemoveParticipantResponse.base:type_name -> common.v1.BaseResponse + 46, // 16: iam.v1.LeaveConversationResponse.base:type_name -> common.v1.BaseResponse + 46, // 17: iam.v1.SendMessageResponse.base:type_name -> common.v1.BaseResponse + 2, // 18: iam.v1.SendMessageResponse.data:type_name -> iam.v1.MessageProto + 46, // 19: iam.v1.EditMessageResponse.base:type_name -> common.v1.BaseResponse + 2, // 20: iam.v1.EditMessageResponse.data:type_name -> iam.v1.MessageProto + 46, // 21: iam.v1.DeleteMessageResponse.base:type_name -> common.v1.BaseResponse + 46, // 22: iam.v1.ListMessagesResponse.base:type_name -> common.v1.BaseResponse + 2, // 23: iam.v1.ListMessagesResponse.data:type_name -> iam.v1.MessageProto + 46, // 24: iam.v1.MarkConversationReadResponse.base:type_name -> common.v1.BaseResponse + 46, // 25: iam.v1.SetTypingResponse.base:type_name -> common.v1.BaseResponse + 46, // 26: iam.v1.GetMessageEditHistoryResponse.base:type_name -> common.v1.BaseResponse + 4, // 27: iam.v1.GetMessageEditHistoryResponse.data:type_name -> iam.v1.EditHistoryEntryProto + 46, // 28: iam.v1.HeartbeatResponse.base:type_name -> common.v1.BaseResponse + 46, // 29: iam.v1.GetOnlineUsersResponse.base:type_name -> common.v1.BaseResponse + 41, // 30: iam.v1.StreamChatEventsResponse.message_received:type_name -> iam.v1.MessageEvent + 41, // 31: iam.v1.StreamChatEventsResponse.message_edited:type_name -> iam.v1.MessageEvent + 42, // 32: iam.v1.StreamChatEventsResponse.message_deleted:type_name -> iam.v1.DeleteEvent + 43, // 33: iam.v1.StreamChatEventsResponse.typing:type_name -> iam.v1.TypingEvent + 44, // 34: iam.v1.StreamChatEventsResponse.read_receipt:type_name -> iam.v1.ReadEvent + 45, // 35: iam.v1.StreamChatEventsResponse.presence:type_name -> iam.v1.PresenceEvent + 2, // 36: iam.v1.MessageEvent.message:type_name -> iam.v1.MessageProto + 5, // 37: iam.v1.ChatService.CreateDirectConversation:input_type -> iam.v1.CreateDirectConversationRequest + 6, // 38: iam.v1.ChatService.CreateGroupConversation:input_type -> iam.v1.CreateGroupConversationRequest + 7, // 39: iam.v1.ChatService.GetConversation:input_type -> iam.v1.GetConversationRequest + 8, // 40: iam.v1.ChatService.ListConversations:input_type -> iam.v1.ListConversationsRequest + 9, // 41: iam.v1.ChatService.UpdateGroupConversation:input_type -> iam.v1.UpdateGroupConversationRequest + 10, // 42: iam.v1.ChatService.AddParticipants:input_type -> iam.v1.AddParticipantsRequest + 11, // 43: iam.v1.ChatService.RemoveParticipant:input_type -> iam.v1.RemoveParticipantRequest + 12, // 44: iam.v1.ChatService.LeaveConversation:input_type -> iam.v1.LeaveConversationRequest + 13, // 45: iam.v1.ChatService.SendMessage:input_type -> iam.v1.SendMessageRequest + 14, // 46: iam.v1.ChatService.EditMessage:input_type -> iam.v1.EditMessageRequest + 15, // 47: iam.v1.ChatService.DeleteMessage:input_type -> iam.v1.DeleteMessageRequest + 16, // 48: iam.v1.ChatService.ListMessages:input_type -> iam.v1.ListMessagesRequest + 17, // 49: iam.v1.ChatService.StreamChatEvents:input_type -> iam.v1.StreamChatEventsRequest + 18, // 50: iam.v1.ChatService.MarkConversationRead:input_type -> iam.v1.MarkConversationReadRequest + 19, // 51: iam.v1.ChatService.SetTyping:input_type -> iam.v1.SetTypingRequest + 20, // 52: iam.v1.ChatService.GetMessageEditHistory:input_type -> iam.v1.GetMessageEditHistoryRequest + 21, // 53: iam.v1.PresenceService.Heartbeat:input_type -> iam.v1.HeartbeatRequest + 22, // 54: iam.v1.PresenceService.GetOnlineUsers:input_type -> iam.v1.GetOnlineUsersRequest + 23, // 55: iam.v1.ChatService.CreateDirectConversation:output_type -> iam.v1.CreateDirectConversationResponse + 24, // 56: iam.v1.ChatService.CreateGroupConversation:output_type -> iam.v1.CreateGroupConversationResponse + 25, // 57: iam.v1.ChatService.GetConversation:output_type -> iam.v1.GetConversationResponse + 26, // 58: iam.v1.ChatService.ListConversations:output_type -> iam.v1.ListConversationsResponse + 27, // 59: iam.v1.ChatService.UpdateGroupConversation:output_type -> iam.v1.UpdateGroupConversationResponse + 28, // 60: iam.v1.ChatService.AddParticipants:output_type -> iam.v1.AddParticipantsResponse + 29, // 61: iam.v1.ChatService.RemoveParticipant:output_type -> iam.v1.RemoveParticipantResponse + 30, // 62: iam.v1.ChatService.LeaveConversation:output_type -> iam.v1.LeaveConversationResponse + 31, // 63: iam.v1.ChatService.SendMessage:output_type -> iam.v1.SendMessageResponse + 32, // 64: iam.v1.ChatService.EditMessage:output_type -> iam.v1.EditMessageResponse + 33, // 65: iam.v1.ChatService.DeleteMessage:output_type -> iam.v1.DeleteMessageResponse + 34, // 66: iam.v1.ChatService.ListMessages:output_type -> iam.v1.ListMessagesResponse + 40, // 67: iam.v1.ChatService.StreamChatEvents:output_type -> iam.v1.StreamChatEventsResponse + 35, // 68: iam.v1.ChatService.MarkConversationRead:output_type -> iam.v1.MarkConversationReadResponse + 36, // 69: iam.v1.ChatService.SetTyping:output_type -> iam.v1.SetTypingResponse + 37, // 70: iam.v1.ChatService.GetMessageEditHistory:output_type -> iam.v1.GetMessageEditHistoryResponse + 38, // 71: iam.v1.PresenceService.Heartbeat:output_type -> iam.v1.HeartbeatResponse + 39, // 72: iam.v1.PresenceService.GetOnlineUsers:output_type -> iam.v1.GetOnlineUsersResponse + 55, // [55:73] is the sub-list for method output_type + 37, // [37:55] is the sub-list for method input_type + 37, // [37:37] is the sub-list for extension type_name + 37, // [37:37] is the sub-list for extension extendee + 0, // [0:37] is the sub-list for field type_name +} + +func init() { file_iam_v1_chat_proto_init() } +func file_iam_v1_chat_proto_init() { + if File_iam_v1_chat_proto != nil { + return + } + file_iam_v1_chat_proto_msgTypes[40].OneofWrappers = []any{ + (*StreamChatEventsResponse_MessageReceived)(nil), + (*StreamChatEventsResponse_MessageEdited)(nil), + (*StreamChatEventsResponse_MessageDeleted)(nil), + (*StreamChatEventsResponse_Typing)(nil), + (*StreamChatEventsResponse_ReadReceipt)(nil), + (*StreamChatEventsResponse_Presence)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_iam_v1_chat_proto_rawDesc), len(file_iam_v1_chat_proto_rawDesc)), + NumEnums: 0, + NumMessages: 46, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_iam_v1_chat_proto_goTypes, + DependencyIndexes: file_iam_v1_chat_proto_depIdxs, + MessageInfos: file_iam_v1_chat_proto_msgTypes, + }.Build() + File_iam_v1_chat_proto = out.File + file_iam_v1_chat_proto_goTypes = nil + file_iam_v1_chat_proto_depIdxs = nil +} diff --git a/gen/iam/v1/chat.pb.gw.go b/gen/iam/v1/chat.pb.gw.go new file mode 100644 index 00000000..247087f2 --- /dev/null +++ b/gen/iam/v1/chat.pb.gw.go @@ -0,0 +1,1602 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: iam/v1/chat.proto + +/* +Package iamv1 is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package iamv1 + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +func request_ChatService_CreateDirectConversation_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateDirectConversationRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.CreateDirectConversation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_CreateDirectConversation_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateDirectConversationRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CreateDirectConversation(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_CreateGroupConversation_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateGroupConversationRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.CreateGroupConversation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_CreateGroupConversation_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateGroupConversationRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CreateGroupConversation(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_GetConversation_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConversationRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.GetConversation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_GetConversation_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConversationRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.GetConversation(ctx, &protoReq) + return msg, metadata, err +} + +var filter_ChatService_ListConversations_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_ChatService_ListConversations_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListConversationsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_ChatService_ListConversations_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListConversations(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_ListConversations_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListConversationsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_ChatService_ListConversations_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListConversations(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_UpdateGroupConversation_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpdateGroupConversationRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.UpdateGroupConversation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_UpdateGroupConversation_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpdateGroupConversationRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.UpdateGroupConversation(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_AddParticipants_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddParticipantsRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.AddParticipants(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_AddParticipants_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddParticipantsRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.AddParticipants(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_RemoveParticipant_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveParticipantRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["user_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "user_id") + } + protoReq.UserId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "user_id", err) + } + msg, err := client.RemoveParticipant(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_RemoveParticipant_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveParticipantRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["user_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "user_id") + } + protoReq.UserId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "user_id", err) + } + msg, err := server.RemoveParticipant(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_LeaveConversation_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LeaveConversationRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.LeaveConversation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_LeaveConversation_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LeaveConversationRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.LeaveConversation(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_SendMessage_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SendMessageRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.SendMessage(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_SendMessage_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SendMessageRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.SendMessage(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_EditMessage_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EditMessageRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["message_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "message_id") + } + protoReq.MessageId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "message_id", err) + } + msg, err := client.EditMessage(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_EditMessage_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EditMessageRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["message_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "message_id") + } + protoReq.MessageId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "message_id", err) + } + msg, err := server.EditMessage(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_DeleteMessage_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteMessageRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["message_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "message_id") + } + protoReq.MessageId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "message_id", err) + } + msg, err := client.DeleteMessage(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_DeleteMessage_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteMessageRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["message_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "message_id") + } + protoReq.MessageId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "message_id", err) + } + msg, err := server.DeleteMessage(ctx, &protoReq) + return msg, metadata, err +} + +var filter_ChatService_ListMessages_0 = &utilities.DoubleArray{Encoding: map[string]int{"conversation_id": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} + +func request_ChatService_ListMessages_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListMessagesRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_ChatService_ListMessages_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListMessages(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_ListMessages_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListMessagesRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_ChatService_ListMessages_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListMessages(ctx, &protoReq) + return msg, metadata, err +} + +var filter_ChatService_StreamChatEvents_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_ChatService_StreamChatEvents_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (ChatService_StreamChatEventsClient, runtime.ServerMetadata, error) { + var ( + protoReq StreamChatEventsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_ChatService_StreamChatEvents_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.StreamChatEvents(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_ChatService_MarkConversationRead_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq MarkConversationReadRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.MarkConversationRead(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_MarkConversationRead_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq MarkConversationReadRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.MarkConversationRead(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_SetTyping_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetTypingRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.SetTyping(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_SetTyping_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetTypingRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.SetTyping(ctx, &protoReq) + return msg, metadata, err +} + +func request_ChatService_GetMessageEditHistory_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetMessageEditHistoryRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["message_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "message_id") + } + protoReq.MessageId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "message_id", err) + } + msg, err := client.GetMessageEditHistory(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_GetMessageEditHistory_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetMessageEditHistoryRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + val, ok = pathParams["message_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "message_id") + } + protoReq.MessageId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "message_id", err) + } + msg, err := server.GetMessageEditHistory(ctx, &protoReq) + return msg, metadata, err +} + +func request_PresenceService_Heartbeat_0(ctx context.Context, marshaler runtime.Marshaler, client PresenceServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq HeartbeatRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.Heartbeat(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PresenceService_Heartbeat_0(ctx context.Context, marshaler runtime.Marshaler, server PresenceServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq HeartbeatRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Heartbeat(ctx, &protoReq) + return msg, metadata, err +} + +var filter_PresenceService_GetOnlineUsers_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_PresenceService_GetOnlineUsers_0(ctx context.Context, marshaler runtime.Marshaler, client PresenceServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetOnlineUsersRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_PresenceService_GetOnlineUsers_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetOnlineUsers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PresenceService_GetOnlineUsers_0(ctx context.Context, marshaler runtime.Marshaler, server PresenceServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetOnlineUsersRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_PresenceService_GetOnlineUsers_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetOnlineUsers(ctx, &protoReq) + return msg, metadata, err +} + +// RegisterChatServiceHandlerServer registers the http handlers for service ChatService to "mux". +// UnaryRPC :call ChatServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterChatServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterChatServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server ChatServiceServer) error { + mux.Handle(http.MethodPost, pattern_ChatService_CreateDirectConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/CreateDirectConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/direct")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_CreateDirectConversation_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_CreateDirectConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_CreateGroupConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/CreateGroupConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/group")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_CreateGroupConversation_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_CreateGroupConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_GetConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/GetConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_GetConversation_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_GetConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_ListConversations_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/ListConversations", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_ListConversations_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_ListConversations_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_ChatService_UpdateGroupConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/UpdateGroupConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_UpdateGroupConversation_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_UpdateGroupConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_AddParticipants_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/AddParticipants", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/participants")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_AddParticipants_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_AddParticipants_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_ChatService_RemoveParticipant_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/RemoveParticipant", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/participants/{user_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_RemoveParticipant_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_RemoveParticipant_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_LeaveConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/LeaveConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/leave")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_LeaveConversation_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_LeaveConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_SendMessage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/SendMessage", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_SendMessage_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_SendMessage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_ChatService_EditMessage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/EditMessage", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_EditMessage_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_EditMessage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_ChatService_DeleteMessage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/DeleteMessage", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_DeleteMessage_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_DeleteMessage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_ListMessages_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/ListMessages", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_ListMessages_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_ListMessages_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodGet, pattern_ChatService_StreamChatEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_ChatService_MarkConversationRead_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/MarkConversationRead", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/read")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_MarkConversationRead_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_MarkConversationRead_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_SetTyping_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/SetTyping", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/typing")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_SetTyping_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_SetTyping_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_GetMessageEditHistory_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/GetMessageEditHistory", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}/history")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_GetMessageEditHistory_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_GetMessageEditHistory_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterPresenceServiceHandlerServer registers the http handlers for service PresenceService to "mux". +// UnaryRPC :call PresenceServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterPresenceServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterPresenceServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server PresenceServiceServer) error { + mux.Handle(http.MethodPost, pattern_PresenceService_Heartbeat_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.PresenceService/Heartbeat", runtime.WithHTTPPathPattern("/api/v1/iam/presence/heartbeat")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PresenceService_Heartbeat_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PresenceService_Heartbeat_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PresenceService_GetOnlineUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.PresenceService/GetOnlineUsers", runtime.WithHTTPPathPattern("/api/v1/iam/presence/online")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PresenceService_GetOnlineUsers_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PresenceService_GetOnlineUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterChatServiceHandlerFromEndpoint is same as RegisterChatServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterChatServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterChatServiceHandler(ctx, mux, conn) +} + +// RegisterChatServiceHandler registers the http handlers for service ChatService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterChatServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterChatServiceHandlerClient(ctx, mux, NewChatServiceClient(conn)) +} + +// RegisterChatServiceHandlerClient registers the http handlers for service ChatService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "ChatServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "ChatServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "ChatServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterChatServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client ChatServiceClient) error { + mux.Handle(http.MethodPost, pattern_ChatService_CreateDirectConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/CreateDirectConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/direct")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_CreateDirectConversation_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_CreateDirectConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_CreateGroupConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/CreateGroupConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/group")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_CreateGroupConversation_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_CreateGroupConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_GetConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/GetConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_GetConversation_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_GetConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_ListConversations_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/ListConversations", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_ListConversations_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_ListConversations_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_ChatService_UpdateGroupConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/UpdateGroupConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_UpdateGroupConversation_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_UpdateGroupConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_AddParticipants_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/AddParticipants", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/participants")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_AddParticipants_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_AddParticipants_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_ChatService_RemoveParticipant_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/RemoveParticipant", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/participants/{user_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_RemoveParticipant_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_RemoveParticipant_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_LeaveConversation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/LeaveConversation", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/leave")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_LeaveConversation_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_LeaveConversation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_SendMessage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/SendMessage", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_SendMessage_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_SendMessage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_ChatService_EditMessage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/EditMessage", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_EditMessage_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_EditMessage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_ChatService_DeleteMessage_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/DeleteMessage", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_DeleteMessage_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_DeleteMessage_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_ListMessages_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/ListMessages", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_ListMessages_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_ListMessages_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_StreamChatEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/StreamChatEvents", runtime.WithHTTPPathPattern("/api/v1/iam/chat/events")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_StreamChatEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_StreamChatEvents_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_MarkConversationRead_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/MarkConversationRead", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/read")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_MarkConversationRead_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_MarkConversationRead_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_ChatService_SetTyping_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/SetTyping", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/typing")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_SetTyping_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_SetTyping_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_ChatService_GetMessageEditHistory_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/GetMessageEditHistory", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}/history")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_GetMessageEditHistory_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_GetMessageEditHistory_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_ChatService_CreateDirectConversation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "iam", "chat", "conversations", "direct"}, "")) + pattern_ChatService_CreateGroupConversation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 2, 5}, []string{"api", "v1", "iam", "chat", "conversations", "group"}, "")) + pattern_ChatService_GetConversation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id"}, "")) + pattern_ChatService_ListConversations_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "iam", "chat", "conversations"}, "")) + pattern_ChatService_UpdateGroupConversation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id"}, "")) + pattern_ChatService_AddParticipants_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "participants"}, "")) + pattern_ChatService_RemoveParticipant_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6, 1, 0, 4, 1, 5, 7}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "participants", "user_id"}, "")) + pattern_ChatService_LeaveConversation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "leave"}, "")) + pattern_ChatService_SendMessage_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "messages"}, "")) + pattern_ChatService_EditMessage_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6, 1, 0, 4, 1, 5, 7}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "messages", "message_id"}, "")) + pattern_ChatService_DeleteMessage_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6, 1, 0, 4, 1, 5, 7}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "messages", "message_id"}, "")) + pattern_ChatService_ListMessages_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "messages"}, "")) + pattern_ChatService_StreamChatEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "iam", "chat", "events"}, "")) + pattern_ChatService_MarkConversationRead_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "read"}, "")) + pattern_ChatService_SetTyping_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "typing"}, "")) + pattern_ChatService_GetMessageEditHistory_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6, 1, 0, 4, 1, 5, 7, 2, 8}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "messages", "message_id", "history"}, "")) +) + +var ( + forward_ChatService_CreateDirectConversation_0 = runtime.ForwardResponseMessage + forward_ChatService_CreateGroupConversation_0 = runtime.ForwardResponseMessage + forward_ChatService_GetConversation_0 = runtime.ForwardResponseMessage + forward_ChatService_ListConversations_0 = runtime.ForwardResponseMessage + forward_ChatService_UpdateGroupConversation_0 = runtime.ForwardResponseMessage + forward_ChatService_AddParticipants_0 = runtime.ForwardResponseMessage + forward_ChatService_RemoveParticipant_0 = runtime.ForwardResponseMessage + forward_ChatService_LeaveConversation_0 = runtime.ForwardResponseMessage + forward_ChatService_SendMessage_0 = runtime.ForwardResponseMessage + forward_ChatService_EditMessage_0 = runtime.ForwardResponseMessage + forward_ChatService_DeleteMessage_0 = runtime.ForwardResponseMessage + forward_ChatService_ListMessages_0 = runtime.ForwardResponseMessage + forward_ChatService_StreamChatEvents_0 = runtime.ForwardResponseStream + forward_ChatService_MarkConversationRead_0 = runtime.ForwardResponseMessage + forward_ChatService_SetTyping_0 = runtime.ForwardResponseMessage + forward_ChatService_GetMessageEditHistory_0 = runtime.ForwardResponseMessage +) + +// RegisterPresenceServiceHandlerFromEndpoint is same as RegisterPresenceServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterPresenceServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterPresenceServiceHandler(ctx, mux, conn) +} + +// RegisterPresenceServiceHandler registers the http handlers for service PresenceService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterPresenceServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterPresenceServiceHandlerClient(ctx, mux, NewPresenceServiceClient(conn)) +} + +// RegisterPresenceServiceHandlerClient registers the http handlers for service PresenceService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "PresenceServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "PresenceServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "PresenceServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterPresenceServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client PresenceServiceClient) error { + mux.Handle(http.MethodPost, pattern_PresenceService_Heartbeat_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.PresenceService/Heartbeat", runtime.WithHTTPPathPattern("/api/v1/iam/presence/heartbeat")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PresenceService_Heartbeat_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PresenceService_Heartbeat_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PresenceService_GetOnlineUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.PresenceService/GetOnlineUsers", runtime.WithHTTPPathPattern("/api/v1/iam/presence/online")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PresenceService_GetOnlineUsers_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PresenceService_GetOnlineUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_PresenceService_Heartbeat_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "iam", "presence", "heartbeat"}, "")) + pattern_PresenceService_GetOnlineUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "iam", "presence", "online"}, "")) +) + +var ( + forward_PresenceService_Heartbeat_0 = runtime.ForwardResponseMessage + forward_PresenceService_GetOnlineUsers_0 = runtime.ForwardResponseMessage +) diff --git a/gen/iam/v1/chat_grpc.pb.go b/gen/iam/v1/chat_grpc.pb.go new file mode 100644 index 00000000..f099ea70 --- /dev/null +++ b/gen/iam/v1/chat_grpc.pb.go @@ -0,0 +1,879 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: iam/v1/chat.proto + +package iamv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ChatService_CreateDirectConversation_FullMethodName = "/iam.v1.ChatService/CreateDirectConversation" + ChatService_CreateGroupConversation_FullMethodName = "/iam.v1.ChatService/CreateGroupConversation" + ChatService_GetConversation_FullMethodName = "/iam.v1.ChatService/GetConversation" + ChatService_ListConversations_FullMethodName = "/iam.v1.ChatService/ListConversations" + ChatService_UpdateGroupConversation_FullMethodName = "/iam.v1.ChatService/UpdateGroupConversation" + ChatService_AddParticipants_FullMethodName = "/iam.v1.ChatService/AddParticipants" + ChatService_RemoveParticipant_FullMethodName = "/iam.v1.ChatService/RemoveParticipant" + ChatService_LeaveConversation_FullMethodName = "/iam.v1.ChatService/LeaveConversation" + ChatService_SendMessage_FullMethodName = "/iam.v1.ChatService/SendMessage" + ChatService_EditMessage_FullMethodName = "/iam.v1.ChatService/EditMessage" + ChatService_DeleteMessage_FullMethodName = "/iam.v1.ChatService/DeleteMessage" + ChatService_ListMessages_FullMethodName = "/iam.v1.ChatService/ListMessages" + ChatService_StreamChatEvents_FullMethodName = "/iam.v1.ChatService/StreamChatEvents" + ChatService_MarkConversationRead_FullMethodName = "/iam.v1.ChatService/MarkConversationRead" + ChatService_SetTyping_FullMethodName = "/iam.v1.ChatService/SetTyping" + ChatService_GetMessageEditHistory_FullMethodName = "/iam.v1.ChatService/GetMessageEditHistory" +) + +// ChatServiceClient is the client API for ChatService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ChatService manages real-time chat conversations and messaging. +type ChatServiceClient interface { + // CreateDirectConversation creates a 1:1 direct message conversation with another user. + CreateDirectConversation(ctx context.Context, in *CreateDirectConversationRequest, opts ...grpc.CallOption) (*CreateDirectConversationResponse, error) + // CreateGroupConversation creates a group conversation with multiple participants. + CreateGroupConversation(ctx context.Context, in *CreateGroupConversationRequest, opts ...grpc.CallOption) (*CreateGroupConversationResponse, error) + // GetConversation returns a single conversation by ID. + GetConversation(ctx context.Context, in *GetConversationRequest, opts ...grpc.CallOption) (*GetConversationResponse, error) + // ListConversations returns paginated conversations for the current user. + ListConversations(ctx context.Context, in *ListConversationsRequest, opts ...grpc.CallOption) (*ListConversationsResponse, error) + // UpdateGroupConversation updates a group conversation name or avatar. + UpdateGroupConversation(ctx context.Context, in *UpdateGroupConversationRequest, opts ...grpc.CallOption) (*UpdateGroupConversationResponse, error) + // AddParticipants adds users to a group conversation. + AddParticipants(ctx context.Context, in *AddParticipantsRequest, opts ...grpc.CallOption) (*AddParticipantsResponse, error) + // RemoveParticipant removes a user from a group conversation. + RemoveParticipant(ctx context.Context, in *RemoveParticipantRequest, opts ...grpc.CallOption) (*RemoveParticipantResponse, error) + // LeaveConversation allows the current user to leave a group conversation. + LeaveConversation(ctx context.Context, in *LeaveConversationRequest, opts ...grpc.CallOption) (*LeaveConversationResponse, error) + // SendMessage sends a new message to a conversation. + SendMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (*SendMessageResponse, error) + // EditMessage edits an existing message body. + EditMessage(ctx context.Context, in *EditMessageRequest, opts ...grpc.CallOption) (*EditMessageResponse, error) + // DeleteMessage soft-deletes a message in a conversation. + DeleteMessage(ctx context.Context, in *DeleteMessageRequest, opts ...grpc.CallOption) (*DeleteMessageResponse, error) + // ListMessages returns paginated messages for a conversation using cursor-based pagination. + ListMessages(ctx context.Context, in *ListMessagesRequest, opts ...grpc.CallOption) (*ListMessagesResponse, error) + // StreamChatEvents opens a server-streaming connection for real-time chat events. + StreamChatEvents(ctx context.Context, in *StreamChatEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamChatEventsResponse], error) + // MarkConversationRead marks all messages in a conversation as read by the current user. + MarkConversationRead(ctx context.Context, in *MarkConversationReadRequest, opts ...grpc.CallOption) (*MarkConversationReadResponse, error) + // SetTyping sets the typing indicator for the current user in a conversation. + SetTyping(ctx context.Context, in *SetTypingRequest, opts ...grpc.CallOption) (*SetTypingResponse, error) + // GetMessageEditHistory returns the edit history for a specific message. + GetMessageEditHistory(ctx context.Context, in *GetMessageEditHistoryRequest, opts ...grpc.CallOption) (*GetMessageEditHistoryResponse, error) +} + +type chatServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewChatServiceClient(cc grpc.ClientConnInterface) ChatServiceClient { + return &chatServiceClient{cc} +} + +func (c *chatServiceClient) CreateDirectConversation(ctx context.Context, in *CreateDirectConversationRequest, opts ...grpc.CallOption) (*CreateDirectConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateDirectConversationResponse) + err := c.cc.Invoke(ctx, ChatService_CreateDirectConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) CreateGroupConversation(ctx context.Context, in *CreateGroupConversationRequest, opts ...grpc.CallOption) (*CreateGroupConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateGroupConversationResponse) + err := c.cc.Invoke(ctx, ChatService_CreateGroupConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) GetConversation(ctx context.Context, in *GetConversationRequest, opts ...grpc.CallOption) (*GetConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetConversationResponse) + err := c.cc.Invoke(ctx, ChatService_GetConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) ListConversations(ctx context.Context, in *ListConversationsRequest, opts ...grpc.CallOption) (*ListConversationsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListConversationsResponse) + err := c.cc.Invoke(ctx, ChatService_ListConversations_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) UpdateGroupConversation(ctx context.Context, in *UpdateGroupConversationRequest, opts ...grpc.CallOption) (*UpdateGroupConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateGroupConversationResponse) + err := c.cc.Invoke(ctx, ChatService_UpdateGroupConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) AddParticipants(ctx context.Context, in *AddParticipantsRequest, opts ...grpc.CallOption) (*AddParticipantsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AddParticipantsResponse) + err := c.cc.Invoke(ctx, ChatService_AddParticipants_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) RemoveParticipant(ctx context.Context, in *RemoveParticipantRequest, opts ...grpc.CallOption) (*RemoveParticipantResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RemoveParticipantResponse) + err := c.cc.Invoke(ctx, ChatService_RemoveParticipant_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) LeaveConversation(ctx context.Context, in *LeaveConversationRequest, opts ...grpc.CallOption) (*LeaveConversationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LeaveConversationResponse) + err := c.cc.Invoke(ctx, ChatService_LeaveConversation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) SendMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (*SendMessageResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendMessageResponse) + err := c.cc.Invoke(ctx, ChatService_SendMessage_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) EditMessage(ctx context.Context, in *EditMessageRequest, opts ...grpc.CallOption) (*EditMessageResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EditMessageResponse) + err := c.cc.Invoke(ctx, ChatService_EditMessage_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) DeleteMessage(ctx context.Context, in *DeleteMessageRequest, opts ...grpc.CallOption) (*DeleteMessageResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteMessageResponse) + err := c.cc.Invoke(ctx, ChatService_DeleteMessage_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) ListMessages(ctx context.Context, in *ListMessagesRequest, opts ...grpc.CallOption) (*ListMessagesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListMessagesResponse) + err := c.cc.Invoke(ctx, ChatService_ListMessages_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) StreamChatEvents(ctx context.Context, in *StreamChatEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamChatEventsResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ChatService_ServiceDesc.Streams[0], ChatService_StreamChatEvents_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StreamChatEventsRequest, StreamChatEventsResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ChatService_StreamChatEventsClient = grpc.ServerStreamingClient[StreamChatEventsResponse] + +func (c *chatServiceClient) MarkConversationRead(ctx context.Context, in *MarkConversationReadRequest, opts ...grpc.CallOption) (*MarkConversationReadResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MarkConversationReadResponse) + err := c.cc.Invoke(ctx, ChatService_MarkConversationRead_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) SetTyping(ctx context.Context, in *SetTypingRequest, opts ...grpc.CallOption) (*SetTypingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetTypingResponse) + err := c.cc.Invoke(ctx, ChatService_SetTyping_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) GetMessageEditHistory(ctx context.Context, in *GetMessageEditHistoryRequest, opts ...grpc.CallOption) (*GetMessageEditHistoryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetMessageEditHistoryResponse) + err := c.cc.Invoke(ctx, ChatService_GetMessageEditHistory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ChatServiceServer is the server API for ChatService service. +// All implementations must embed UnimplementedChatServiceServer +// for forward compatibility. +// +// ChatService manages real-time chat conversations and messaging. +type ChatServiceServer interface { + // CreateDirectConversation creates a 1:1 direct message conversation with another user. + CreateDirectConversation(context.Context, *CreateDirectConversationRequest) (*CreateDirectConversationResponse, error) + // CreateGroupConversation creates a group conversation with multiple participants. + CreateGroupConversation(context.Context, *CreateGroupConversationRequest) (*CreateGroupConversationResponse, error) + // GetConversation returns a single conversation by ID. + GetConversation(context.Context, *GetConversationRequest) (*GetConversationResponse, error) + // ListConversations returns paginated conversations for the current user. + ListConversations(context.Context, *ListConversationsRequest) (*ListConversationsResponse, error) + // UpdateGroupConversation updates a group conversation name or avatar. + UpdateGroupConversation(context.Context, *UpdateGroupConversationRequest) (*UpdateGroupConversationResponse, error) + // AddParticipants adds users to a group conversation. + AddParticipants(context.Context, *AddParticipantsRequest) (*AddParticipantsResponse, error) + // RemoveParticipant removes a user from a group conversation. + RemoveParticipant(context.Context, *RemoveParticipantRequest) (*RemoveParticipantResponse, error) + // LeaveConversation allows the current user to leave a group conversation. + LeaveConversation(context.Context, *LeaveConversationRequest) (*LeaveConversationResponse, error) + // SendMessage sends a new message to a conversation. + SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) + // EditMessage edits an existing message body. + EditMessage(context.Context, *EditMessageRequest) (*EditMessageResponse, error) + // DeleteMessage soft-deletes a message in a conversation. + DeleteMessage(context.Context, *DeleteMessageRequest) (*DeleteMessageResponse, error) + // ListMessages returns paginated messages for a conversation using cursor-based pagination. + ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error) + // StreamChatEvents opens a server-streaming connection for real-time chat events. + StreamChatEvents(*StreamChatEventsRequest, grpc.ServerStreamingServer[StreamChatEventsResponse]) error + // MarkConversationRead marks all messages in a conversation as read by the current user. + MarkConversationRead(context.Context, *MarkConversationReadRequest) (*MarkConversationReadResponse, error) + // SetTyping sets the typing indicator for the current user in a conversation. + SetTyping(context.Context, *SetTypingRequest) (*SetTypingResponse, error) + // GetMessageEditHistory returns the edit history for a specific message. + GetMessageEditHistory(context.Context, *GetMessageEditHistoryRequest) (*GetMessageEditHistoryResponse, error) + mustEmbedUnimplementedChatServiceServer() +} + +// UnimplementedChatServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedChatServiceServer struct{} + +func (UnimplementedChatServiceServer) CreateDirectConversation(context.Context, *CreateDirectConversationRequest) (*CreateDirectConversationResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateDirectConversation not implemented") +} +func (UnimplementedChatServiceServer) CreateGroupConversation(context.Context, *CreateGroupConversationRequest) (*CreateGroupConversationResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateGroupConversation not implemented") +} +func (UnimplementedChatServiceServer) GetConversation(context.Context, *GetConversationRequest) (*GetConversationResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetConversation not implemented") +} +func (UnimplementedChatServiceServer) ListConversations(context.Context, *ListConversationsRequest) (*ListConversationsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListConversations not implemented") +} +func (UnimplementedChatServiceServer) UpdateGroupConversation(context.Context, *UpdateGroupConversationRequest) (*UpdateGroupConversationResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateGroupConversation not implemented") +} +func (UnimplementedChatServiceServer) AddParticipants(context.Context, *AddParticipantsRequest) (*AddParticipantsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AddParticipants not implemented") +} +func (UnimplementedChatServiceServer) RemoveParticipant(context.Context, *RemoveParticipantRequest) (*RemoveParticipantResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveParticipant not implemented") +} +func (UnimplementedChatServiceServer) LeaveConversation(context.Context, *LeaveConversationRequest) (*LeaveConversationResponse, error) { + return nil, status.Error(codes.Unimplemented, "method LeaveConversation not implemented") +} +func (UnimplementedChatServiceServer) SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SendMessage not implemented") +} +func (UnimplementedChatServiceServer) EditMessage(context.Context, *EditMessageRequest) (*EditMessageResponse, error) { + return nil, status.Error(codes.Unimplemented, "method EditMessage not implemented") +} +func (UnimplementedChatServiceServer) DeleteMessage(context.Context, *DeleteMessageRequest) (*DeleteMessageResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteMessage not implemented") +} +func (UnimplementedChatServiceServer) ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListMessages not implemented") +} +func (UnimplementedChatServiceServer) StreamChatEvents(*StreamChatEventsRequest, grpc.ServerStreamingServer[StreamChatEventsResponse]) error { + return status.Error(codes.Unimplemented, "method StreamChatEvents not implemented") +} +func (UnimplementedChatServiceServer) MarkConversationRead(context.Context, *MarkConversationReadRequest) (*MarkConversationReadResponse, error) { + return nil, status.Error(codes.Unimplemented, "method MarkConversationRead not implemented") +} +func (UnimplementedChatServiceServer) SetTyping(context.Context, *SetTypingRequest) (*SetTypingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetTyping not implemented") +} +func (UnimplementedChatServiceServer) GetMessageEditHistory(context.Context, *GetMessageEditHistoryRequest) (*GetMessageEditHistoryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetMessageEditHistory not implemented") +} +func (UnimplementedChatServiceServer) mustEmbedUnimplementedChatServiceServer() {} +func (UnimplementedChatServiceServer) testEmbeddedByValue() {} + +// UnsafeChatServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ChatServiceServer will +// result in compilation errors. +type UnsafeChatServiceServer interface { + mustEmbedUnimplementedChatServiceServer() +} + +func RegisterChatServiceServer(s grpc.ServiceRegistrar, srv ChatServiceServer) { + // If the following call panics, it indicates UnimplementedChatServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ChatService_ServiceDesc, srv) +} + +func _ChatService_CreateDirectConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateDirectConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).CreateDirectConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_CreateDirectConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).CreateDirectConversation(ctx, req.(*CreateDirectConversationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_CreateGroupConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateGroupConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).CreateGroupConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_CreateGroupConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).CreateGroupConversation(ctx, req.(*CreateGroupConversationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_GetConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).GetConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_GetConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).GetConversation(ctx, req.(*GetConversationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_ListConversations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListConversationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).ListConversations(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_ListConversations_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).ListConversations(ctx, req.(*ListConversationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_UpdateGroupConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateGroupConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).UpdateGroupConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_UpdateGroupConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).UpdateGroupConversation(ctx, req.(*UpdateGroupConversationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_AddParticipants_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddParticipantsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).AddParticipants(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_AddParticipants_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).AddParticipants(ctx, req.(*AddParticipantsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_RemoveParticipant_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RemoveParticipantRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).RemoveParticipant(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_RemoveParticipant_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).RemoveParticipant(ctx, req.(*RemoveParticipantRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_LeaveConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LeaveConversationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).LeaveConversation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_LeaveConversation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).LeaveConversation(ctx, req.(*LeaveConversationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_SendMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendMessageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).SendMessage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_SendMessage_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).SendMessage(ctx, req.(*SendMessageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_EditMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EditMessageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).EditMessage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_EditMessage_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).EditMessage(ctx, req.(*EditMessageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_DeleteMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteMessageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).DeleteMessage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_DeleteMessage_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).DeleteMessage(ctx, req.(*DeleteMessageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_ListMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListMessagesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).ListMessages(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_ListMessages_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).ListMessages(ctx, req.(*ListMessagesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_StreamChatEvents_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StreamChatEventsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ChatServiceServer).StreamChatEvents(m, &grpc.GenericServerStream[StreamChatEventsRequest, StreamChatEventsResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ChatService_StreamChatEventsServer = grpc.ServerStreamingServer[StreamChatEventsResponse] + +func _ChatService_MarkConversationRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MarkConversationReadRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).MarkConversationRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_MarkConversationRead_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).MarkConversationRead(ctx, req.(*MarkConversationReadRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_SetTyping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetTypingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).SetTyping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_SetTyping_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).SetTyping(ctx, req.(*SetTypingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_GetMessageEditHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetMessageEditHistoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).GetMessageEditHistory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_GetMessageEditHistory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).GetMessageEditHistory(ctx, req.(*GetMessageEditHistoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ChatService_ServiceDesc is the grpc.ServiceDesc for ChatService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ChatService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "iam.v1.ChatService", + HandlerType: (*ChatServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CreateDirectConversation", + Handler: _ChatService_CreateDirectConversation_Handler, + }, + { + MethodName: "CreateGroupConversation", + Handler: _ChatService_CreateGroupConversation_Handler, + }, + { + MethodName: "GetConversation", + Handler: _ChatService_GetConversation_Handler, + }, + { + MethodName: "ListConversations", + Handler: _ChatService_ListConversations_Handler, + }, + { + MethodName: "UpdateGroupConversation", + Handler: _ChatService_UpdateGroupConversation_Handler, + }, + { + MethodName: "AddParticipants", + Handler: _ChatService_AddParticipants_Handler, + }, + { + MethodName: "RemoveParticipant", + Handler: _ChatService_RemoveParticipant_Handler, + }, + { + MethodName: "LeaveConversation", + Handler: _ChatService_LeaveConversation_Handler, + }, + { + MethodName: "SendMessage", + Handler: _ChatService_SendMessage_Handler, + }, + { + MethodName: "EditMessage", + Handler: _ChatService_EditMessage_Handler, + }, + { + MethodName: "DeleteMessage", + Handler: _ChatService_DeleteMessage_Handler, + }, + { + MethodName: "ListMessages", + Handler: _ChatService_ListMessages_Handler, + }, + { + MethodName: "MarkConversationRead", + Handler: _ChatService_MarkConversationRead_Handler, + }, + { + MethodName: "SetTyping", + Handler: _ChatService_SetTyping_Handler, + }, + { + MethodName: "GetMessageEditHistory", + Handler: _ChatService_GetMessageEditHistory_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamChatEvents", + Handler: _ChatService_StreamChatEvents_Handler, + ServerStreams: true, + }, + }, + Metadata: "iam/v1/chat.proto", +} + +const ( + PresenceService_Heartbeat_FullMethodName = "/iam.v1.PresenceService/Heartbeat" + PresenceService_GetOnlineUsers_FullMethodName = "/iam.v1.PresenceService/GetOnlineUsers" +) + +// PresenceServiceClient is the client API for PresenceService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// PresenceService tracks user online status via heartbeat and exposes online user lists. +type PresenceServiceClient interface { + // Heartbeat updates the current user online presence with a TTL-based expiry. + Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) + // GetOnlineUsers returns the list of currently online user IDs. + GetOnlineUsers(ctx context.Context, in *GetOnlineUsersRequest, opts ...grpc.CallOption) (*GetOnlineUsersResponse, error) +} + +type presenceServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewPresenceServiceClient(cc grpc.ClientConnInterface) PresenceServiceClient { + return &presenceServiceClient{cc} +} + +func (c *presenceServiceClient) Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HeartbeatResponse) + err := c.cc.Invoke(ctx, PresenceService_Heartbeat_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *presenceServiceClient) GetOnlineUsers(ctx context.Context, in *GetOnlineUsersRequest, opts ...grpc.CallOption) (*GetOnlineUsersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetOnlineUsersResponse) + err := c.cc.Invoke(ctx, PresenceService_GetOnlineUsers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PresenceServiceServer is the server API for PresenceService service. +// All implementations must embed UnimplementedPresenceServiceServer +// for forward compatibility. +// +// PresenceService tracks user online status via heartbeat and exposes online user lists. +type PresenceServiceServer interface { + // Heartbeat updates the current user online presence with a TTL-based expiry. + Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) + // GetOnlineUsers returns the list of currently online user IDs. + GetOnlineUsers(context.Context, *GetOnlineUsersRequest) (*GetOnlineUsersResponse, error) + mustEmbedUnimplementedPresenceServiceServer() +} + +// UnimplementedPresenceServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPresenceServiceServer struct{} + +func (UnimplementedPresenceServiceServer) Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Heartbeat not implemented") +} +func (UnimplementedPresenceServiceServer) GetOnlineUsers(context.Context, *GetOnlineUsersRequest) (*GetOnlineUsersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetOnlineUsers not implemented") +} +func (UnimplementedPresenceServiceServer) mustEmbedUnimplementedPresenceServiceServer() {} +func (UnimplementedPresenceServiceServer) testEmbeddedByValue() {} + +// UnsafePresenceServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PresenceServiceServer will +// result in compilation errors. +type UnsafePresenceServiceServer interface { + mustEmbedUnimplementedPresenceServiceServer() +} + +func RegisterPresenceServiceServer(s grpc.ServiceRegistrar, srv PresenceServiceServer) { + // If the following call panics, it indicates UnimplementedPresenceServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&PresenceService_ServiceDesc, srv) +} + +func _PresenceService_Heartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HeartbeatRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PresenceServiceServer).Heartbeat(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PresenceService_Heartbeat_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PresenceServiceServer).Heartbeat(ctx, req.(*HeartbeatRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PresenceService_GetOnlineUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetOnlineUsersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PresenceServiceServer).GetOnlineUsers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PresenceService_GetOnlineUsers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PresenceServiceServer).GetOnlineUsers(ctx, req.(*GetOnlineUsersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// PresenceService_ServiceDesc is the grpc.ServiceDesc for PresenceService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var PresenceService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "iam.v1.PresenceService", + HandlerType: (*PresenceServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Heartbeat", + Handler: _PresenceService_Heartbeat_Handler, + }, + { + MethodName: "GetOnlineUsers", + Handler: _PresenceService_GetOnlineUsers_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "iam/v1/chat.proto", +} diff --git a/gen/openapi/iam/v1/chat.swagger.json b/gen/openapi/iam/v1/chat.swagger.json new file mode 100644 index 00000000..0fe2fa37 --- /dev/null +++ b/gen/openapi/iam/v1/chat.swagger.json @@ -0,0 +1,1338 @@ +{ + "swagger": "2.0", + "info": { + "title": "iam/v1/chat.proto", + "version": "version not set" + }, + "tags": [ + { + "name": "ChatService" + }, + { + "name": "PresenceService" + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/api/v1/iam/chat/conversations": { + "get": { + "summary": "ListConversations returns paginated conversations for the current user.", + "operationId": "ChatService_ListConversations", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ListConversationsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "search", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/direct": { + "post": { + "summary": "CreateDirectConversation creates a 1:1 direct message conversation with another user.", + "operationId": "ChatService_CreateDirectConversation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1CreateDirectConversationResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "description": "CreateDirectConversationRequest creates a 1:1 direct conversation.", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1CreateDirectConversationRequest" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/group": { + "post": { + "summary": "CreateGroupConversation creates a group conversation with multiple participants.", + "operationId": "ChatService_CreateGroupConversation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1CreateGroupConversationResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "description": "CreateGroupConversationRequest creates a group conversation.", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1CreateGroupConversationRequest" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}": { + "get": { + "summary": "GetConversation returns a single conversation by ID.", + "operationId": "ChatService_GetConversation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetConversationResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + }, + "put": { + "summary": "UpdateGroupConversation updates a group conversation name or avatar.", + "operationId": "ChatService_UpdateGroupConversation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1UpdateGroupConversationResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceUpdateGroupConversationBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/leave": { + "post": { + "summary": "LeaveConversation allows the current user to leave a group conversation.", + "operationId": "ChatService_LeaveConversation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1LeaveConversationResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceLeaveConversationBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/messages": { + "get": { + "summary": "ListMessages returns paginated messages for a conversation using cursor-based pagination.", + "operationId": "ChatService_ListMessages", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ListMessagesResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "beforeCursor", + "description": "Opaque base64 cursor; empty = start from latest messages.", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + }, + "post": { + "summary": "SendMessage sends a new message to a conversation.", + "operationId": "ChatService_SendMessage", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1SendMessageResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceSendMessageBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/messages/{messageId}": { + "delete": { + "summary": "DeleteMessage soft-deletes a message in a conversation.", + "operationId": "ChatService_DeleteMessage", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1DeleteMessageResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "messageId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + }, + "put": { + "summary": "EditMessage edits an existing message body.", + "operationId": "ChatService_EditMessage", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1EditMessageResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "messageId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceEditMessageBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/messages/{messageId}/history": { + "get": { + "summary": "GetMessageEditHistory returns the edit history for a specific message.", + "operationId": "ChatService_GetMessageEditHistory", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetMessageEditHistoryResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "messageId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/participants": { + "post": { + "summary": "AddParticipants adds users to a group conversation.", + "operationId": "ChatService_AddParticipants", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1AddParticipantsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceAddParticipantsBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/participants/{userId}": { + "delete": { + "summary": "RemoveParticipant removes a user from a group conversation.", + "operationId": "ChatService_RemoveParticipant", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1RemoveParticipantResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "userId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/read": { + "post": { + "summary": "MarkConversationRead marks all messages in a conversation as read by the current user.", + "operationId": "ChatService_MarkConversationRead", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1MarkConversationReadResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceMarkConversationReadBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/typing": { + "post": { + "summary": "SetTyping sets the typing indicator for the current user in a conversation.", + "operationId": "ChatService_SetTyping", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1SetTypingResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceSetTypingBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/events": { + "get": { + "summary": "StreamChatEvents opens a server-streaming connection for real-time chat events.", + "operationId": "ChatService_StreamChatEvents", + "responses": { + "200": { + "description": "A successful response.(streaming responses)", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1StreamChatEventsResponse" + }, + "error": { + "$ref": "#/definitions/rpcStatus" + } + }, + "title": "Stream result of v1StreamChatEventsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "lastEventId", + "description": "Last event ID seen by the client; used to resume the stream after reconnect.", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/presence/heartbeat": { + "post": { + "summary": "Heartbeat updates the current user online presence with a TTL-based expiry.", + "operationId": "PresenceService_Heartbeat", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1HeartbeatResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "description": "HeartbeatRequest sends a heartbeat to update online presence.", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1HeartbeatRequest" + } + } + ], + "tags": [ + "PresenceService" + ] + } + }, + "/api/v1/iam/presence/online": { + "get": { + "summary": "GetOnlineUsers returns the list of currently online user IDs.", + "operationId": "PresenceService_GetOnlineUsers", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetOnlineUsersResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "userIds", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi" + } + ], + "tags": [ + "PresenceService" + ] + } + } + }, + "definitions": { + "ChatServiceAddParticipantsBody": { + "type": "object", + "properties": { + "userIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "description": "AddParticipantsRequest adds users to a group conversation." + }, + "ChatServiceEditMessageBody": { + "type": "object", + "properties": { + "body": { + "type": "string" + } + }, + "description": "EditMessageRequest edits an existing message." + }, + "ChatServiceLeaveConversationBody": { + "type": "object", + "description": "LeaveConversationRequest allows the current user to leave a group conversation." + }, + "ChatServiceMarkConversationReadBody": { + "type": "object", + "description": "MarkConversationReadRequest marks a conversation as read." + }, + "ChatServiceSendMessageBody": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "replyToId": { + "type": "string", + "description": "ID of the message being replied to (optional, empty = no reply)." + } + }, + "description": "SendMessageRequest sends a message to a conversation." + }, + "ChatServiceSetTypingBody": { + "type": "object", + "properties": { + "isTyping": { + "type": "boolean" + } + }, + "description": "SetTypingRequest sets the typing indicator for the current user." + }, + "ChatServiceUpdateGroupConversationBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } + }, + "description": "UpdateGroupConversationRequest updates a group conversation." + }, + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + }, + "v1AddParticipantsResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "AddParticipantsResponse confirms participants were added." + }, + "v1BaseResponse": { + "type": "object", + "properties": { + "validationErrors": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ValidationError" + }, + "description": "List of validation errors if any." + }, + "statusCode": { + "type": "string", + "description": "HTTP-like status code (e.g., \"200\", \"400\", \"404\", \"500\")." + }, + "isSuccess": { + "type": "boolean", + "description": "Indicates if the operation was successful." + }, + "message": { + "type": "string", + "description": "Human-readable message describing the result." + } + }, + "description": "BaseResponse is the standard response wrapper for all API responses." + }, + "v1ConversationProto": { + "type": "object", + "properties": { + "conversationId": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ParticipantProto" + } + }, + "lastMessage": { + "$ref": "#/definitions/v1MessageProto" + }, + "unreadCount": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "description": "ConversationProto represents a chat conversation (direct or group)." + }, + "v1CreateDirectConversationRequest": { + "type": "object", + "properties": { + "peerUserId": { + "type": "string" + } + }, + "description": "CreateDirectConversationRequest creates a 1:1 direct conversation." + }, + "v1CreateDirectConversationResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "$ref": "#/definitions/v1ConversationProto" + } + }, + "description": "CreateDirectConversationResponse returns the created direct conversation." + }, + "v1CreateGroupConversationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "participantIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "description": "CreateGroupConversationRequest creates a group conversation." + }, + "v1CreateGroupConversationResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "$ref": "#/definitions/v1ConversationProto" + } + }, + "description": "CreateGroupConversationResponse returns the created group conversation." + }, + "v1DeleteEvent": { + "type": "object", + "properties": { + "conversationId": { + "type": "string" + }, + "messageId": { + "type": "string" + } + }, + "description": "DeleteEvent represents a message deletion event." + }, + "v1DeleteMessageResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "DeleteMessageResponse confirms a message was deleted." + }, + "v1EditHistoryEntryProto": { + "type": "object", + "properties": { + "historyId": { + "type": "string", + "format": "int64" + }, + "body": { + "type": "string" + }, + "editedBy": { + "type": "string" + }, + "editedAt": { + "type": "string" + } + }, + "description": "EditHistoryEntryProto represents a single edit history entry for a message." + }, + "v1EditMessageResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "$ref": "#/definitions/v1MessageProto" + } + }, + "description": "EditMessageResponse returns the edited message." + }, + "v1GetConversationResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "$ref": "#/definitions/v1ConversationProto" + } + }, + "description": "GetConversationResponse returns a single conversation." + }, + "v1GetMessageEditHistoryResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1EditHistoryEntryProto" + } + } + }, + "description": "GetMessageEditHistoryResponse returns the edit history for a message." + }, + "v1GetOnlineUsersResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "userIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "description": "GetOnlineUsersResponse returns the list of online user IDs." + }, + "v1HeartbeatRequest": { + "type": "object", + "description": "HeartbeatRequest sends a heartbeat to update online presence." + }, + "v1HeartbeatResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "HeartbeatResponse confirms the heartbeat was received." + }, + "v1LeaveConversationResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "LeaveConversationResponse confirms the user left the conversation." + }, + "v1ListConversationsResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ConversationProto" + } + }, + "pagination": { + "$ref": "#/definitions/v1PaginationResponse" + } + }, + "description": "ListConversationsResponse returns a paginated list of conversations." + }, + "v1ListMessagesResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1MessageProto" + } + }, + "nextCursor": { + "type": "string" + }, + "hasMore": { + "type": "boolean" + } + }, + "description": "ListMessagesResponse returns a cursor-paginated list of messages." + }, + "v1MarkConversationReadResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "MarkConversationReadResponse confirms the conversation was marked as read." + }, + "v1MessageEvent": { + "type": "object", + "properties": { + "conversationId": { + "type": "string" + }, + "message": { + "$ref": "#/definitions/v1MessageProto" + } + }, + "description": "MessageEvent represents a new or edited message event." + }, + "v1MessageProto": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "conversationId": { + "type": "string" + }, + "senderUserId": { + "type": "string" + }, + "senderName": { + "type": "string" + }, + "body": { + "type": "string" + }, + "isEdited": { + "type": "boolean" + }, + "isDeleted": { + "type": "boolean" + }, + "replyToId": { + "type": "string" + }, + "readReceipts": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ReadReceiptProto" + } + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "description": "MessageProto represents a single chat message with decrypted body." + }, + "v1PaginationResponse": { + "type": "object", + "properties": { + "currentPage": { + "type": "integer", + "format": "int32", + "description": "Current page number." + }, + "pageSize": { + "type": "integer", + "format": "int32", + "description": "Number of items per page." + }, + "totalItems": { + "type": "string", + "format": "int64", + "description": "Total number of items across all pages." + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages." + } + }, + "description": "PaginationResponse contains pagination metadata for list responses." + }, + "v1ParticipantProto": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "fullName": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "role": { + "type": "string" + }, + "isOnline": { + "type": "boolean" + }, + "joinedAt": { + "type": "string" + } + }, + "description": "ParticipantProto represents a user participating in a conversation." + }, + "v1PresenceEvent": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "isOnline": { + "type": "boolean" + } + }, + "description": "PresenceEvent represents a user online/offline status change." + }, + "v1ReadEvent": { + "type": "object", + "properties": { + "conversationId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "readAt": { + "type": "string" + } + }, + "description": "ReadEvent represents a read receipt event." + }, + "v1ReadReceiptProto": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "readAt": { + "type": "string" + } + }, + "description": "ReadReceiptProto represents a read receipt for a message." + }, + "v1RemoveParticipantResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "RemoveParticipantResponse confirms a participant was removed." + }, + "v1SendMessageResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "$ref": "#/definitions/v1MessageProto" + } + }, + "description": "SendMessageResponse returns the sent message." + }, + "v1SetTypingResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "SetTypingResponse confirms the typing indicator was set." + }, + "v1StreamChatEventsResponse": { + "type": "object", + "properties": { + "eventId": { + "type": "string" + }, + "messageReceived": { + "$ref": "#/definitions/v1MessageEvent" + }, + "messageEdited": { + "$ref": "#/definitions/v1MessageEvent" + }, + "messageDeleted": { + "$ref": "#/definitions/v1DeleteEvent" + }, + "typing": { + "$ref": "#/definitions/v1TypingEvent" + }, + "readReceipt": { + "$ref": "#/definitions/v1ReadEvent" + }, + "presence": { + "$ref": "#/definitions/v1PresenceEvent" + } + }, + "description": "StreamChatEventsResponse wraps a real-time chat event delivered via server streaming." + }, + "v1TypingEvent": { + "type": "object", + "properties": { + "conversationId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "isTyping": { + "type": "boolean" + } + }, + "description": "TypingEvent represents a typing indicator event." + }, + "v1UpdateGroupConversationResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "$ref": "#/definitions/v1ConversationProto" + } + }, + "description": "UpdateGroupConversationResponse returns the updated group conversation." + }, + "v1ValidationError": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "The field name that failed validation." + }, + "message": { + "type": "string", + "description": "The validation error message." + } + }, + "description": "ValidationError represents a single field validation error." + } + } +} diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index 75dd61fc..1ea74b30 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -11,12 +11,17 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "encoding/hex" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" authapp "github.com/mutugading/goapps-backend/services/iam/internal/application/auth" + appChat "github.com/mutugading/goapps-backend/services/iam/internal/application/chat" appnotif "github.com/mutugading/goapps-backend/services/iam/internal/application/notification" grpcdelivery "github.com/mutugading/goapps-backend/services/iam/internal/delivery/grpc" httpdelivery "github.com/mutugading/goapps-backend/services/iam/internal/delivery/httpdelivery" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/config" + iamcrypto "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" emailinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/email" "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/jwt" notifinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/notification" @@ -204,6 +209,54 @@ func run() error { notifStream, validationHelper, ).WithRequestHandler(notifRequestHandler) + // ── Chat + Presence ──────────────────────────────────────────────────── + chatMasterKeyHex := os.Getenv("CHAT_MASTER_KEY") + var chatHandler *grpcdelivery.ChatHandler + var presenceGRPCHandler *grpcdelivery.PresenceHandler + if chatMasterKeyHex != "" { + chatMasterKey, hexErr := hex.DecodeString(chatMasterKeyHex) + if hexErr != nil || len(chatMasterKey) != 32 { + log.Fatal().Msg("CHAT_MASTER_KEY must be a 64-char hex string (32 bytes)") + } + chatEnc, encErr := iamcrypto.NewEncryptor(chatMasterKey) + if encErr != nil { + log.Fatal().Err(encErr).Msg("Failed to init chat encryptor") + } + var chatBroadcaster *chatinfra.Broadcaster + if redisClient != nil { + chatBroadcaster = chatinfra.NewRedisBroadcaster(redisClient.Client) + } else { + chatBroadcaster = chatinfra.NewBroadcaster() + } + var presenceSvc *chatinfra.PresenceService + if redisClient != nil { + presenceSvc = chatinfra.NewPresenceService(redisClient.Client) + } + chatConvRepo := postgres.NewChatConversationRepository(db) + chatMsgRepo := postgres.NewChatMessageRepository(db) + chatReceiptRepo := postgres.NewChatReadReceiptRepository(db) + chatHandler = grpcdelivery.NewChatHandler( + appChat.NewCreateDirectHandler(chatConvRepo, chatEnc), + appChat.NewCreateGroupHandler(chatConvRepo, chatEnc), + appChat.NewGetConversationHandler(chatConvRepo), + appChat.NewListConversationsHandler(chatConvRepo), + appChat.NewLeaveConversationHandler(chatConvRepo), + appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster), + appChat.NewEditMessageHandler(chatConvRepo, chatMsgRepo, chatEnc, chatBroadcaster), + appChat.NewDeleteMessageHandler(chatConvRepo, chatMsgRepo, chatBroadcaster), + appChat.NewListMessagesHandler(chatConvRepo, chatMsgRepo, chatEnc), + appChat.NewMarkReadHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatBroadcaster), + appChat.NewSetTypingHandler(chatConvRepo, presenceSvc, chatBroadcaster), + appChat.NewStreamHandler(chatBroadcaster), + ) + if presenceSvc != nil { + presenceGRPCHandler = grpcdelivery.NewPresenceHandler(presenceSvc) + } + log.Info().Msg("Chat + Presence services initialized") + } else { + log.Warn().Msg("CHAT_MASTER_KEY not set — chat services disabled") + } + // Setup gRPC server with interceptor chain (pass JWT + session cache + session repo for auth & activity tracking) grpcServer, err := grpcdelivery.NewServer(&cfg.Server, db, jwtService, sessionCache, sessionRepo, cfg.Security.InternalServiceToken) if err != nil { @@ -232,6 +285,12 @@ func run() error { iamv1.RegisterNotificationServiceServer(gs, notificationHandler) iamv1.RegisterWorkflowTemplateServiceServer(gs, workflowTemplateHandler) iamv1.RegisterWorkflowInstanceServiceServer(gs, workflowInstanceHandler) + if chatHandler != nil { + iamv1.RegisterChatServiceServer(gs, chatHandler) + } + if presenceGRPCHandler != nil { + iamv1.RegisterPresenceServiceServer(gs, presenceGRPCHandler) + } // Start gRPC server go func() { diff --git a/services/iam/internal/application/chat/add_participants_handler.go b/services/iam/internal/application/chat/add_participants_handler.go new file mode 100644 index 00000000..8b101b0f --- /dev/null +++ b/services/iam/internal/application/chat/add_participants_handler.go @@ -0,0 +1,43 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// AddParticipantsHandler adds participants to a group conversation. +type AddParticipantsHandler struct { + convRepo chat.ConversationRepository +} + +// NewAddParticipantsHandler constructs the handler. +func NewAddParticipantsHandler(convRepo chat.ConversationRepository) *AddParticipantsHandler { + return &AddParticipantsHandler{convRepo: convRepo} +} + +// Handle adds userIDs to the conversation. Caller must be ADMIN or OWNER. +func (h *AddParticipantsHandler) Handle(ctx context.Context, callerID, convID uuid.UUID, userIDs []uuid.UUID) error { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return fmt.Errorf("add participants: %w", chat.ErrNotParticipant) + } + if !p.Role().IsAdminOrOwner() { + return fmt.Errorf("add participants: %w", chat.ErrNotAdmin) + } + newParts := make([]*chat.Participant, 0, len(userIDs)) + for _, uid := range userIDs { + if err := conv.AddParticipant(uid, chat.RoleMember); err != nil { + return fmt.Errorf("add participants: %w", err) + } + newParts = append(newParts, conv.FindParticipant(uid)) + } + return h.convRepo.AddParticipants(ctx, convID, newParts) +} diff --git a/services/iam/internal/application/chat/broadcast_helpers.go b/services/iam/internal/application/chat/broadcast_helpers.go new file mode 100644 index 00000000..ae5aaa44 --- /dev/null +++ b/services/iam/internal/application/chat/broadcast_helpers.go @@ -0,0 +1,44 @@ +package chat + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/rs/zerolog/log" + + domainChat "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" +) + +// broadcastMessageEvent publishes a message-related event (created, edited) to +// every active participant of conv via the given broadcaster. +func broadcastMessageEvent(broadcaster *chatinfra.Broadcaster, conv *domainChat.Conversation, msg *domainChat.Message, plainBody, eventType string) { + payload := map[string]any{ + "type": eventType, + "conversation_id": conv.ID().String(), + "message_id": msg.MessageID().String(), + "sender_user_id": msg.SenderUserID().String(), + "body": plainBody, + "is_edited": msg.IsEdited(), + "is_deleted": msg.IsDeleted(), + "created_at": msg.CreatedAt().UTC().Format(time.RFC3339Nano), + "updated_at": msg.UpdatedAt().UTC().Format(time.RFC3339Nano), + } + data, err := json.Marshal(payload) + if err != nil { + log.Warn().Err(err).Msg("broadcast message event: marshal payload") + return + } + eventID := fmt.Sprintf("msg-%s", msg.MessageID()) + for _, part := range conv.Participants() { + if !part.IsActive() { + continue + } + broadcaster.Publish(&chatinfra.Event{ + EventID: fmt.Sprintf("%s-%s", eventID, part.UserID()), + UserID: part.UserID(), + Payload: data, + }) + } +} diff --git a/services/iam/internal/application/chat/create_direct_handler.go b/services/iam/internal/application/chat/create_direct_handler.go new file mode 100644 index 00000000..65873b7f --- /dev/null +++ b/services/iam/internal/application/chat/create_direct_handler.go @@ -0,0 +1,51 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" +) + +// CreateDirectHandler creates a 1:1 conversation (idempotent — returns existing if present). +type CreateDirectHandler struct { + convRepo chat.ConversationRepository + enc *crypto.Encryptor +} + +// NewCreateDirectHandler constructs the handler. +func NewCreateDirectHandler(convRepo chat.ConversationRepository, enc *crypto.Encryptor) *CreateDirectHandler { + return &CreateDirectHandler{convRepo: convRepo, enc: enc} +} + +// Handle creates or returns an existing DIRECT conversation between callerID and peerID. +func (h *CreateDirectHandler) Handle(ctx context.Context, callerID, peerID uuid.UUID) (*chat.Conversation, error) { + existing, err := h.convRepo.FindDirect(ctx, callerID, peerID) + if err != nil { + return nil, fmt.Errorf("create direct: find existing: %w", err) + } + if existing != nil { + return existing, nil + } + + convKeyPlain, err := h.enc.GenerateConversationKey() + if err != nil { + return nil, fmt.Errorf("create direct: generate key: %w", err) + } + encKey, err := h.enc.EncryptConversationKey(convKeyPlain) + if err != nil { + return nil, fmt.Errorf("create direct: encrypt key: %w", err) + } + + conv, err := chat.NewDirectConversation(callerID, peerID, convKeyPlain, encKey) + if err != nil { + return nil, fmt.Errorf("create direct: build: %w", err) + } + if err := h.convRepo.Create(ctx, conv); err != nil { + return nil, fmt.Errorf("create direct: persist: %w", err) + } + return conv, nil +} diff --git a/services/iam/internal/application/chat/create_group_handler.go b/services/iam/internal/application/chat/create_group_handler.go new file mode 100644 index 00000000..8ac6b926 --- /dev/null +++ b/services/iam/internal/application/chat/create_group_handler.go @@ -0,0 +1,42 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" +) + +// CreateGroupHandler creates a new group conversation. +type CreateGroupHandler struct { + convRepo chat.ConversationRepository + enc *crypto.Encryptor +} + +// NewCreateGroupHandler constructs the handler. +func NewCreateGroupHandler(convRepo chat.ConversationRepository, enc *crypto.Encryptor) *CreateGroupHandler { + return &CreateGroupHandler{convRepo: convRepo, enc: enc} +} + +// Handle creates a group conversation with the caller as OWNER. +func (h *CreateGroupHandler) Handle(ctx context.Context, ownerID uuid.UUID, name string, memberIDs []uuid.UUID) (*chat.Conversation, error) { + convKeyPlain, err := h.enc.GenerateConversationKey() + if err != nil { + return nil, fmt.Errorf("create group: generate key: %w", err) + } + encKey, err := h.enc.EncryptConversationKey(convKeyPlain) + if err != nil { + return nil, fmt.Errorf("create group: encrypt key: %w", err) + } + conv, err := chat.NewGroupConversation(ownerID, name, memberIDs, convKeyPlain, encKey) + if err != nil { + return nil, fmt.Errorf("create group: build: %w", err) + } + if err := h.convRepo.Create(ctx, conv); err != nil { + return nil, fmt.Errorf("create group: persist: %w", err) + } + return conv, nil +} diff --git a/services/iam/internal/application/chat/delete_message_handler.go b/services/iam/internal/application/chat/delete_message_handler.go new file mode 100644 index 00000000..3bb5806d --- /dev/null +++ b/services/iam/internal/application/chat/delete_message_handler.go @@ -0,0 +1,71 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" +) + +// DeleteMessageHandler soft-deletes a message. +type DeleteMessageHandler struct { + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + broadcaster *chatinfra.Broadcaster +} + +// NewDeleteMessageHandler constructs the handler. +func NewDeleteMessageHandler(convRepo chat.ConversationRepository, msgRepo chat.MessageRepository, broadcaster *chatinfra.Broadcaster) *DeleteMessageHandler { + return &DeleteMessageHandler{convRepo: convRepo, msgRepo: msgRepo, broadcaster: broadcaster} +} + +// Handle soft-deletes the message. Only author or conversation ADMIN/OWNER. +func (h *DeleteMessageHandler) Handle(ctx context.Context, callerID, convID, msgID uuid.UUID) error { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return err + } + msg, err := h.msgRepo.GetByID(ctx, msgID) + if err != nil { + return err + } + if msg.SenderUserID() != callerID { + p := conv.FindParticipant(callerID) + if p == nil || !p.Role().IsAdminOrOwner() { + return fmt.Errorf("delete message: %w", chat.ErrNotAuthor) + } + } + if err := h.msgRepo.MarkDeleted(ctx, msgID); err != nil { + return fmt.Errorf("delete message: %w", err) + } + h.broadcastDelete(conv, convID, msgID) + return nil +} + +func (h *DeleteMessageHandler) broadcastDelete(conv *chat.Conversation, convID, msgID uuid.UUID) { + payload, err := json.Marshal(map[string]string{ + "type": "message_deleted", + "conversation_id": convID.String(), + "message_id": msgID.String(), + }) + if err != nil { + log.Warn().Err(err).Msg("delete message: marshal broadcast") + return + } + eventID := fmt.Sprintf("del-%s", msgID) + for _, p := range conv.Participants() { + if !p.IsActive() { + continue + } + h.broadcaster.Publish(&chatinfra.Event{ + EventID: fmt.Sprintf("%s-%s", eventID, p.UserID()), + UserID: p.UserID(), + Payload: payload, + }) + } +} diff --git a/services/iam/internal/application/chat/edit_message_handler.go b/services/iam/internal/application/chat/edit_message_handler.go new file mode 100644 index 00000000..787eeaaa --- /dev/null +++ b/services/iam/internal/application/chat/edit_message_handler.go @@ -0,0 +1,75 @@ +package chat + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" +) + +// EditMessageHandler handles message edits. +type EditMessageHandler struct { + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + enc *crypto.Encryptor + broadcaster *chatinfra.Broadcaster +} + +// NewEditMessageHandler constructs the handler. +func NewEditMessageHandler(convRepo chat.ConversationRepository, msgRepo chat.MessageRepository, enc *crypto.Encryptor, broadcaster *chatinfra.Broadcaster) *EditMessageHandler { + return &EditMessageHandler{convRepo: convRepo, msgRepo: msgRepo, enc: enc, broadcaster: broadcaster} +} + +// Handle edits a message. Only the author can edit; saves edit history. +func (h *EditMessageHandler) Handle(ctx context.Context, callerID, convID, msgID uuid.UUID, newBody string) (*chat.Message, error) { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return nil, err + } + msg, err := h.msgRepo.GetByID(ctx, msgID) + if err != nil { + return nil, err + } + if msg.IsDeleted() { + return nil, fmt.Errorf("edit message: %w", chat.ErrMessageDeleted) + } + if msg.SenderUserID() != callerID { + p := conv.FindParticipant(callerID) + if p == nil || !p.Role().IsAdminOrOwner() { + return nil, fmt.Errorf("edit message: %w", chat.ErrNotAuthor) + } + } + + convKeyPlain, err := h.enc.DecryptConversationKey(conv.EncryptionKey()) + if err != nil { + return nil, fmt.Errorf("edit message: decrypt conv key: %w", err) + } + + // Save edit history snapshot before mutating. + histEntry := chat.ReconstructEditHistory(0, msgID, msg.BodyEncrypted(), callerID, time.Now().UTC()) + if saveErr := h.msgRepo.SaveEditHistory(ctx, histEntry); saveErr != nil { + log.Warn().Err(saveErr).Msg("edit message: save history") + } + + newBodyEnc, err := h.enc.EncryptMessage(convKeyPlain, newBody) + if err != nil { + return nil, fmt.Errorf("edit message: encrypt new body: %w", err) + } + newBodyPlainEnc, err := h.enc.EncryptMessage(convKeyPlain, newBody) + if err != nil { + return nil, fmt.Errorf("edit message: encrypt plain: %w", err) + } + msg.Edit(newBodyEnc, newBodyPlainEnc) + + if err := h.msgRepo.UpdateBody(ctx, msg); err != nil { + return nil, fmt.Errorf("edit message: update: %w", err) + } + broadcastMessageEvent(h.broadcaster, conv, msg, newBody, "message_edited") + return msg, nil +} diff --git a/services/iam/internal/application/chat/get_conversation_handler.go b/services/iam/internal/application/chat/get_conversation_handler.go new file mode 100644 index 00000000..6fa80a17 --- /dev/null +++ b/services/iam/internal/application/chat/get_conversation_handler.go @@ -0,0 +1,33 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// GetConversationHandler fetches a conversation, enforcing participant check. +type GetConversationHandler struct { + convRepo chat.ConversationRepository +} + +// NewGetConversationHandler constructs the handler. +func NewGetConversationHandler(convRepo chat.ConversationRepository) *GetConversationHandler { + return &GetConversationHandler{convRepo: convRepo} +} + +// Handle returns the conversation if callerID is an active participant. +func (h *GetConversationHandler) Handle(ctx context.Context, callerID, convID uuid.UUID) (*chat.Conversation, error) { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return nil, err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return nil, fmt.Errorf("get conversation: %w", chat.ErrNotParticipant) + } + return conv, nil +} diff --git a/services/iam/internal/application/chat/leave_conversation_handler.go b/services/iam/internal/application/chat/leave_conversation_handler.go new file mode 100644 index 00000000..517a4c56 --- /dev/null +++ b/services/iam/internal/application/chat/leave_conversation_handler.go @@ -0,0 +1,33 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// LeaveConversationHandler removes the calling user from a conversation. +type LeaveConversationHandler struct { + convRepo chat.ConversationRepository +} + +// NewLeaveConversationHandler constructs the handler. +func NewLeaveConversationHandler(convRepo chat.ConversationRepository) *LeaveConversationHandler { + return &LeaveConversationHandler{convRepo: convRepo} +} + +// Handle removes callerID from the conversation. +func (h *LeaveConversationHandler) Handle(ctx context.Context, callerID, convID uuid.UUID) error { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return fmt.Errorf("leave conversation: %w", chat.ErrNotParticipant) + } + return h.convRepo.RemoveParticipant(ctx, convID, callerID) +} diff --git a/services/iam/internal/application/chat/list_conversations_handler.go b/services/iam/internal/application/chat/list_conversations_handler.go new file mode 100644 index 00000000..08ae2afb --- /dev/null +++ b/services/iam/internal/application/chat/list_conversations_handler.go @@ -0,0 +1,35 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// ListConversationsHandler lists conversations for the calling user. +type ListConversationsHandler struct { + convRepo chat.ConversationRepository +} + +// NewListConversationsHandler constructs the handler. +func NewListConversationsHandler(convRepo chat.ConversationRepository) *ListConversationsHandler { + return &ListConversationsHandler{convRepo: convRepo} +} + +// ListResult holds the list result with pagination. +type ListResult struct { + Conversations []*chat.Conversation + Total int64 +} + +// Handle returns paginated conversations for callerID. +func (h *ListConversationsHandler) Handle(ctx context.Context, callerID uuid.UUID, page, pageSize int) (*ListResult, error) { + convs, total, err := h.convRepo.ListByUserID(ctx, callerID, page, pageSize) + if err != nil { + return nil, fmt.Errorf("list conversations: %w", err) + } + return &ListResult{Conversations: convs, Total: total}, nil +} diff --git a/services/iam/internal/application/chat/list_messages_handler.go b/services/iam/internal/application/chat/list_messages_handler.go new file mode 100644 index 00000000..bca35de6 --- /dev/null +++ b/services/iam/internal/application/chat/list_messages_handler.go @@ -0,0 +1,72 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" +) + +// ListMessagesHandler lists messages for a conversation, decrypting bodies. +type ListMessagesHandler struct { + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + enc *crypto.Encryptor +} + +// NewListMessagesHandler constructs the handler. +func NewListMessagesHandler(convRepo chat.ConversationRepository, msgRepo chat.MessageRepository, enc *crypto.Encryptor) *ListMessagesHandler { + return &ListMessagesHandler{convRepo: convRepo, msgRepo: msgRepo, enc: enc} +} + +// DecryptedMessage pairs a domain message with its decrypted body. +type DecryptedMessage struct { + *chat.Message + PlainBody string +} + +// MessagesResult holds the paginated message list. +type MessagesResult struct { + Messages []*DecryptedMessage + NextCursor string + HasMore bool +} + +// Handle fetches and decrypts messages. callerID must be an active participant. +func (h *ListMessagesHandler) Handle(ctx context.Context, callerID, convID uuid.UUID, pageSize int, beforeCursor string) (*MessagesResult, error) { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return nil, err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return nil, fmt.Errorf("list messages: %w", chat.ErrNotParticipant) + } + + convKeyPlain, err := h.enc.DecryptConversationKey(conv.EncryptionKey()) + if err != nil { + return nil, fmt.Errorf("list messages: decrypt conv key: %w", err) + } + + msgs, nextCursor, hasMore, err := h.msgRepo.ListByConversation(ctx, convID, pageSize, beforeCursor) + if err != nil { + return nil, fmt.Errorf("list messages: %w", err) + } + + decrypted := make([]*DecryptedMessage, 0, len(msgs)) + for _, msg := range msgs { + plain := "[deleted]" + if !msg.IsDeleted() { + var decErr error + plain, decErr = h.enc.DecryptMessage(convKeyPlain, msg.BodyEncrypted()) + if decErr != nil { + plain = "[decryption error]" + } + } + decrypted = append(decrypted, &DecryptedMessage{Message: msg, PlainBody: plain}) + } + return &MessagesResult{Messages: decrypted, NextCursor: nextCursor, HasMore: hasMore}, nil +} diff --git a/services/iam/internal/application/chat/mark_read_handler.go b/services/iam/internal/application/chat/mark_read_handler.go new file mode 100644 index 00000000..d89731ff --- /dev/null +++ b/services/iam/internal/application/chat/mark_read_handler.go @@ -0,0 +1,93 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" +) + +// MarkReadHandler marks all unread messages in a conversation as read. +type MarkReadHandler struct { + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + receiptRepo chat.ReadReceiptRepository + broadcaster *chatinfra.Broadcaster +} + +// NewMarkReadHandler constructs the handler. +func NewMarkReadHandler( + convRepo chat.ConversationRepository, + msgRepo chat.MessageRepository, + receiptRepo chat.ReadReceiptRepository, + broadcaster *chatinfra.Broadcaster, +) *MarkReadHandler { + return &MarkReadHandler{convRepo: convRepo, msgRepo: msgRepo, receiptRepo: receiptRepo, broadcaster: broadcaster} +} + +// Handle upserts read receipts for recent messages and updates last_read_at. +func (h *MarkReadHandler) Handle(ctx context.Context, callerID, convID uuid.UUID) error { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return fmt.Errorf("mark read: %w", chat.ErrNotParticipant) + } + + // Fetch recent messages (up to last 100) and upsert receipts. + msgs, _, _, err := h.msgRepo.ListByConversation(ctx, convID, 100, "") + if err != nil { + return fmt.Errorf("mark read: list: %w", err) + } + + ids := make([]uuid.UUID, 0, len(msgs)) + for _, m := range msgs { + if !m.IsDeleted() { + ids = append(ids, m.MessageID()) + } + } + if err := h.receiptRepo.UpsertBulk(ctx, ids, callerID); err != nil { + return fmt.Errorf("mark read: upsert receipts: %w", err) + } + + now := time.Now().UTC() + if err := h.convRepo.UpdateLastReadAt(ctx, convID, callerID, now); err != nil { + log.Warn().Err(err).Msg("mark read: update last_read_at") + } + + // Broadcast read receipt event to conversation participants. + h.broadcastRead(conv, callerID, now) + return nil +} + +func (h *MarkReadHandler) broadcastRead(conv *chat.Conversation, readerID uuid.UUID, readAt time.Time) { + payload, err := json.Marshal(map[string]string{ + "type": "read_receipt", + "conversation_id": conv.ID().String(), + "user_id": readerID.String(), + "read_at": readAt.Format(time.RFC3339Nano), + }) + if err != nil { + log.Warn().Err(err).Msg("mark read: marshal broadcast") + return + } + eventID := fmt.Sprintf("read-%s-%s", conv.ID(), readerID) + for _, p := range conv.Participants() { + if !p.IsActive() { + continue + } + h.broadcaster.Publish(&chatinfra.Event{ + EventID: fmt.Sprintf("%s-%s", eventID, p.UserID()), + UserID: p.UserID(), + Payload: payload, + }) + } +} diff --git a/services/iam/internal/application/chat/remove_participant_handler.go b/services/iam/internal/application/chat/remove_participant_handler.go new file mode 100644 index 00000000..c4cbb3c5 --- /dev/null +++ b/services/iam/internal/application/chat/remove_participant_handler.go @@ -0,0 +1,40 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// RemoveParticipantHandler removes a participant from a group conversation. +type RemoveParticipantHandler struct { + convRepo chat.ConversationRepository +} + +// NewRemoveParticipantHandler constructs the handler. +func NewRemoveParticipantHandler(convRepo chat.ConversationRepository) *RemoveParticipantHandler { + return &RemoveParticipantHandler{convRepo: convRepo} +} + +// Handle removes targetUserID from the conversation. Caller must be ADMIN/OWNER. +func (h *RemoveParticipantHandler) Handle(ctx context.Context, callerID, convID, targetUserID uuid.UUID) error { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return fmt.Errorf("remove participant: %w", chat.ErrNotParticipant) + } + if !p.Role().IsAdminOrOwner() { + return fmt.Errorf("remove participant: %w", chat.ErrNotAdmin) + } + target := conv.FindParticipant(targetUserID) + if target == nil || !target.IsActive() { + return fmt.Errorf("remove participant: target %w", chat.ErrNotParticipant) + } + return h.convRepo.RemoveParticipant(ctx, convID, targetUserID) +} diff --git a/services/iam/internal/application/chat/send_message_handler.go b/services/iam/internal/application/chat/send_message_handler.go new file mode 100644 index 00000000..bb1fb17b --- /dev/null +++ b/services/iam/internal/application/chat/send_message_handler.go @@ -0,0 +1,72 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" +) + +// SendMessageHandler sends a new message to a conversation. +type SendMessageHandler struct { + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + receiptRepo chat.ReadReceiptRepository + enc *crypto.Encryptor + broadcaster *chatinfra.Broadcaster +} + +// NewSendMessageHandler constructs the handler. +func NewSendMessageHandler( + convRepo chat.ConversationRepository, + msgRepo chat.MessageRepository, + receiptRepo chat.ReadReceiptRepository, + enc *crypto.Encryptor, + broadcaster *chatinfra.Broadcaster, +) *SendMessageHandler { + return &SendMessageHandler{convRepo: convRepo, msgRepo: msgRepo, receiptRepo: receiptRepo, enc: enc, broadcaster: broadcaster} +} + +// Handle validates participation, encrypts body, saves, and broadcasts. +func (h *SendMessageHandler) Handle(ctx context.Context, senderID, convID uuid.UUID, body string, replyToID uuid.UUID) (*chat.Message, error) { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return nil, err + } + p := conv.FindParticipant(senderID) + if p == nil || !p.IsActive() { + return nil, fmt.Errorf("send message: %w", chat.ErrNotParticipant) + } + + convKeyPlain, err := h.enc.DecryptConversationKey(conv.EncryptionKey()) + if err != nil { + return nil, fmt.Errorf("send message: decrypt conv key: %w", err) + } + + bodyEnc, err := h.enc.EncryptMessage(convKeyPlain, body) + if err != nil { + return nil, fmt.Errorf("send message: encrypt body: %w", err) + } + bodyPlainEnc, err := h.enc.EncryptMessage(convKeyPlain, body) // same body, independent nonce for search index slot + if err != nil { + return nil, fmt.Errorf("send message: encrypt plain: %w", err) + } + + msg := chat.NewMessage(convID, senderID, bodyEnc, bodyPlainEnc, replyToID) + if err := h.msgRepo.Create(ctx, msg); err != nil { + return nil, fmt.Errorf("send message: persist: %w", err) + } + + // Auto-read for sender. + if err := h.receiptRepo.Upsert(ctx, msg.MessageID(), senderID); err != nil { + log.Warn().Err(err).Msg("send message: auto read receipt failed") + } + + broadcastMessageEvent(h.broadcaster, conv, msg, body, "message_received") + return msg, nil +} diff --git a/services/iam/internal/application/chat/set_typing_handler.go b/services/iam/internal/application/chat/set_typing_handler.go new file mode 100644 index 00000000..d8c9a0f5 --- /dev/null +++ b/services/iam/internal/application/chat/set_typing_handler.go @@ -0,0 +1,64 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" +) + +// SetTypingHandler publishes typing indicator events. +type SetTypingHandler struct { + convRepo chat.ConversationRepository + presence *chatinfra.PresenceService + broadcaster *chatinfra.Broadcaster +} + +// NewSetTypingHandler constructs the handler. +func NewSetTypingHandler(convRepo chat.ConversationRepository, presence *chatinfra.PresenceService, broadcaster *chatinfra.Broadcaster) *SetTypingHandler { + return &SetTypingHandler{convRepo: convRepo, presence: presence, broadcaster: broadcaster} +} + +// Handle sets/clears typing indicator and broadcasts to other participants. +func (h *SetTypingHandler) Handle(ctx context.Context, callerID, convID uuid.UUID, isTyping bool, callerName string) error { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return fmt.Errorf("set typing: %w", chat.ErrNotParticipant) + } + if err := h.presence.SetTyping(ctx, convID, callerID, isTyping); err != nil { + return fmt.Errorf("set typing: %w", err) + } + + payload, err := json.Marshal(map[string]any{ + "type": "typing", + "conversation_id": convID.String(), + "user_id": callerID.String(), + "user_name": callerName, + "is_typing": isTyping, + }) + if err != nil { + log.Warn().Err(err).Msg("set typing: marshal broadcast") + return nil + } + eventID := fmt.Sprintf("typing-%s-%s", convID, callerID) + for _, part := range conv.Participants() { + if !part.IsActive() || part.UserID() == callerID { + continue // don't echo back to sender + } + h.broadcaster.Publish(&chatinfra.Event{ + EventID: fmt.Sprintf("%s-%s", eventID, part.UserID()), + UserID: part.UserID(), + Payload: payload, + }) + } + return nil +} diff --git a/services/iam/internal/application/chat/stream_handler.go b/services/iam/internal/application/chat/stream_handler.go new file mode 100644 index 00000000..44f1b31d --- /dev/null +++ b/services/iam/internal/application/chat/stream_handler.go @@ -0,0 +1,22 @@ +package chat + +import ( + "github.com/google/uuid" + + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" +) + +// StreamHandler subscribes a user to real-time chat events via the broadcaster. +type StreamHandler struct { + broadcaster *chatinfra.Broadcaster +} + +// NewStreamHandler constructs the handler. +func NewStreamHandler(broadcaster *chatinfra.Broadcaster) *StreamHandler { + return &StreamHandler{broadcaster: broadcaster} +} + +// Subscribe returns an event channel and unsub func for the given user. +func (h *StreamHandler) Subscribe(userID uuid.UUID) (<-chan *chatinfra.Event, func()) { + return h.broadcaster.Subscribe(userID) +} diff --git a/services/iam/internal/application/chat/update_group_handler.go b/services/iam/internal/application/chat/update_group_handler.go new file mode 100644 index 00000000..996bed52 --- /dev/null +++ b/services/iam/internal/application/chat/update_group_handler.go @@ -0,0 +1,40 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// UpdateGroupHandler updates a group conversation's name and avatar. +type UpdateGroupHandler struct { + convRepo chat.ConversationRepository +} + +// NewUpdateGroupHandler constructs the handler. +func NewUpdateGroupHandler(convRepo chat.ConversationRepository) *UpdateGroupHandler { + return &UpdateGroupHandler{convRepo: convRepo} +} + +// Handle updates the group conversation. Caller must be ADMIN or OWNER. +func (h *UpdateGroupHandler) Handle(ctx context.Context, callerID, convID uuid.UUID, name, avatarURL string) (*chat.Conversation, error) { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return nil, err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return nil, fmt.Errorf("update group: %w", chat.ErrNotParticipant) + } + if !p.Role().IsAdminOrOwner() { + return nil, fmt.Errorf("update group: %w", chat.ErrNotAdmin) + } + conv.UpdateGroup(name, avatarURL) + if err := h.convRepo.UpdateGroup(ctx, conv); err != nil { + return nil, fmt.Errorf("update group: persist: %w", err) + } + return conv, nil +} diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go new file mode 100644 index 00000000..30b874f9 --- /dev/null +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -0,0 +1,377 @@ +package grpc + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/mutugading/goapps-backend/gen/common/v1" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" + appChat "github.com/mutugading/goapps-backend/services/iam/internal/application/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// ChatHandler implements iamv1.ChatServiceServer. +type ChatHandler struct { + iamv1.UnimplementedChatServiceServer + createDirect *appChat.CreateDirectHandler + createGroup *appChat.CreateGroupHandler + getConv *appChat.GetConversationHandler + listConvs *appChat.ListConversationsHandler + leaveConv *appChat.LeaveConversationHandler + sendMsg *appChat.SendMessageHandler + editMsg *appChat.EditMessageHandler + deleteMsg *appChat.DeleteMessageHandler + listMsgs *appChat.ListMessagesHandler + markRead *appChat.MarkReadHandler + setTyping *appChat.SetTypingHandler + stream *appChat.StreamHandler +} + +// NewChatHandler constructs the handler. +func NewChatHandler( + createDirect *appChat.CreateDirectHandler, + createGroup *appChat.CreateGroupHandler, + getConv *appChat.GetConversationHandler, + listConvs *appChat.ListConversationsHandler, + leaveConv *appChat.LeaveConversationHandler, + sendMsg *appChat.SendMessageHandler, + editMsg *appChat.EditMessageHandler, + deleteMsg *appChat.DeleteMessageHandler, + listMsgs *appChat.ListMessagesHandler, + markRead *appChat.MarkReadHandler, + setTyping *appChat.SetTypingHandler, + stream *appChat.StreamHandler, +) *ChatHandler { + return &ChatHandler{ + createDirect: createDirect, createGroup: createGroup, + getConv: getConv, listConvs: listConvs, leaveConv: leaveConv, + sendMsg: sendMsg, editMsg: editMsg, deleteMsg: deleteMsg, + listMsgs: listMsgs, markRead: markRead, setTyping: setTyping, + stream: stream, + } +} + +// CreateDirectConversation creates a 1:1 direct conversation. +func (h *ChatHandler) CreateDirectConversation(ctx context.Context, req *iamv1.CreateDirectConversationRequest) (*iamv1.CreateDirectConversationResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + peerID, err := uuid.Parse(req.GetPeerUserId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid peer_user_id: %v", err) + } + conv, err := h.createDirect.Handle(ctx, callerID, peerID) + if err != nil { + return nil, mapChatError(err) + } + return &iamv1.CreateDirectConversationResponse{Base: chatSuccessBase(), Data: convToProto(conv)}, nil +} + +// CreateGroupConversation creates a group conversation. +func (h *ChatHandler) CreateGroupConversation(ctx context.Context, req *iamv1.CreateGroupConversationRequest) (*iamv1.CreateGroupConversationResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + memberIDs := make([]uuid.UUID, 0, len(req.GetParticipantIds())) + for _, idStr := range req.GetParticipantIds() { + uid, parseErr := uuid.Parse(idStr) + if parseErr != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid participant_id: %v", parseErr) + } + memberIDs = append(memberIDs, uid) + } + conv, err := h.createGroup.Handle(ctx, callerID, req.GetName(), memberIDs) + if err != nil { + return nil, mapChatError(err) + } + return &iamv1.CreateGroupConversationResponse{Base: chatSuccessBase(), Data: convToProto(conv)}, nil +} + +// GetConversation returns a single conversation. +func (h *ChatHandler) GetConversation(ctx context.Context, req *iamv1.GetConversationRequest) (*iamv1.GetConversationResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + conv, err := h.getConv.Handle(ctx, callerID, convID) + if err != nil { + return nil, mapChatError(err) + } + return &iamv1.GetConversationResponse{Base: chatSuccessBase(), Data: convToProto(conv)}, nil +} + +// ListConversations returns paginated conversations. +func (h *ChatHandler) ListConversations(ctx context.Context, req *iamv1.ListConversationsRequest) (*iamv1.ListConversationsResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + page, pageSize := int(req.GetPage()), int(req.GetPageSize()) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + result, err := h.listConvs.Handle(ctx, callerID, page, pageSize) + if err != nil { + return nil, mapChatError(err) + } + protos := make([]*iamv1.ConversationProto, 0, len(result.Conversations)) + for _, c := range result.Conversations { + protos = append(protos, convToProto(c)) + } + totalPages := int32((result.Total + int64(pageSize) - 1) / int64(pageSize)) //nolint:gosec // bounded by page count + return &iamv1.ListConversationsResponse{ + Base: chatSuccessBase(), + Data: protos, + Pagination: &commonv1.PaginationResponse{ + CurrentPage: int32(page), //nolint:gosec // bounded by request validation + PageSize: int32(pageSize), //nolint:gosec // bounded by request validation + TotalItems: result.Total, + TotalPages: totalPages, + }, + }, nil +} + +// LeaveConversation removes the caller from a conversation. +func (h *ChatHandler) LeaveConversation(ctx context.Context, req *iamv1.LeaveConversationRequest) (*iamv1.LeaveConversationResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + if err := h.leaveConv.Handle(ctx, callerID, convID); err != nil { + return nil, mapChatError(err) + } + return &iamv1.LeaveConversationResponse{Base: chatSuccessBase()}, nil +} + +// SendMessage sends a message to a conversation. +func (h *ChatHandler) SendMessage(ctx context.Context, req *iamv1.SendMessageRequest) (*iamv1.SendMessageResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + replyToID := uuid.Nil + if req.GetReplyToId() != "" { + replyToID, err = uuid.Parse(req.GetReplyToId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid reply_to_id: %v", err) + } + } + msg, err := h.sendMsg.Handle(ctx, callerID, convID, req.GetBody(), replyToID) + if err != nil { + return nil, mapChatError(err) + } + return &iamv1.SendMessageResponse{Base: chatSuccessBase(), Data: msgToProto(msg, req.GetBody(), nil)}, nil +} + +// EditMessage edits an existing message. +func (h *ChatHandler) EditMessage(ctx context.Context, req *iamv1.EditMessageRequest) (*iamv1.EditMessageResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + msgID, err := uuid.Parse(req.GetMessageId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid message_id: %v", err) + } + msg, err := h.editMsg.Handle(ctx, callerID, convID, msgID, req.GetBody()) + if err != nil { + return nil, mapChatError(err) + } + return &iamv1.EditMessageResponse{Base: chatSuccessBase(), Data: msgToProto(msg, req.GetBody(), nil)}, nil +} + +// DeleteMessage soft-deletes a message. +func (h *ChatHandler) DeleteMessage(ctx context.Context, req *iamv1.DeleteMessageRequest) (*iamv1.DeleteMessageResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + msgID, err := uuid.Parse(req.GetMessageId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid message_id: %v", err) + } + if err := h.deleteMsg.Handle(ctx, callerID, convID, msgID); err != nil { + return nil, mapChatError(err) + } + return &iamv1.DeleteMessageResponse{Base: chatSuccessBase()}, nil +} + +// ListMessages returns paginated messages for a conversation. +func (h *ChatHandler) ListMessages(ctx context.Context, req *iamv1.ListMessagesRequest) (*iamv1.ListMessagesResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + pageSize := int(req.GetPageSize()) + if pageSize < 1 { + pageSize = 30 + } + result, err := h.listMsgs.Handle(ctx, callerID, convID, pageSize, req.GetBeforeCursor()) + if err != nil { + return nil, mapChatError(err) + } + protos := make([]*iamv1.MessageProto, 0, len(result.Messages)) + for _, dm := range result.Messages { + protos = append(protos, msgToProto(dm.Message, dm.PlainBody, dm.ReadReceipts())) + } + return &iamv1.ListMessagesResponse{ + Base: chatSuccessBase(), + Data: protos, + NextCursor: result.NextCursor, + HasMore: result.HasMore, + }, nil +} + +// MarkConversationRead marks all messages as read. +func (h *ChatHandler) MarkConversationRead(ctx context.Context, req *iamv1.MarkConversationReadRequest) (*iamv1.MarkConversationReadResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + if err := h.markRead.Handle(ctx, callerID, convID); err != nil { + return nil, mapChatError(err) + } + return &iamv1.MarkConversationReadResponse{Base: chatSuccessBase()}, nil +} + +// SetTyping sets the typing indicator. +func (h *ChatHandler) SetTyping(ctx context.Context, req *iamv1.SetTypingRequest) (*iamv1.SetTypingResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + if err := h.setTyping.Handle(ctx, callerID, convID, req.GetIsTyping(), ""); err != nil { + return nil, mapChatError(err) + } + return &iamv1.SetTypingResponse{Base: chatSuccessBase()}, nil +} + +// StreamChatEvents opens a server-streaming connection for chat events. +func (h *ChatHandler) StreamChatEvents(_ *iamv1.StreamChatEventsRequest, stream iamv1.ChatService_StreamChatEventsServer) error { + ctx := stream.Context() + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return err + } + ch, unsub := h.stream.Subscribe(callerID) + defer unsub() + for { + select { + case <-ctx.Done(): + return nil + case evt, ok := <-ch: + if !ok { + return nil + } + resp := &iamv1.StreamChatEventsResponse{EventId: evt.EventID} + if sendErr := stream.Send(resp); sendErr != nil { + return sendErr + } + } + } +} + +func convToProto(conv *chat.Conversation) *iamv1.ConversationProto { + parts := make([]*iamv1.ParticipantProto, 0, len(conv.Participants())) + for _, p := range conv.Participants() { + parts = append(parts, &iamv1.ParticipantProto{ + UserId: p.UserID().String(), + Role: p.Role().String(), + JoinedAt: p.JoinedAt().UTC().Format(time.RFC3339Nano), + }) + } + return &iamv1.ConversationProto{ + ConversationId: conv.ID().String(), + Type: conv.Type().String(), + Name: conv.Name(), + AvatarUrl: conv.AvatarURL(), + Participants: parts, + CreatedAt: conv.CreatedAt().UTC().Format(time.RFC3339Nano), + UpdatedAt: conv.UpdatedAt().UTC().Format(time.RFC3339Nano), + } +} + +func msgToProto(msg *chat.Message, plainBody string, receipts []*chat.ReadReceipt) *iamv1.MessageProto { + receiptProtos := make([]*iamv1.ReadReceiptProto, 0, len(receipts)) + for _, r := range receipts { + receiptProtos = append(receiptProtos, &iamv1.ReadReceiptProto{ + UserId: r.UserID().String(), + ReadAt: r.ReadAt().UTC().Format(time.RFC3339Nano), + }) + } + replyTo := "" + if msg.ReplyToID() != uuid.Nil { + replyTo = msg.ReplyToID().String() + } + return &iamv1.MessageProto{ + MessageId: msg.MessageID().String(), + ConversationId: msg.ConversationID().String(), + SenderUserId: msg.SenderUserID().String(), + Body: plainBody, + IsEdited: msg.IsEdited(), + IsDeleted: msg.IsDeleted(), + ReplyToId: replyTo, + ReadReceipts: receiptProtos, + CreatedAt: msg.CreatedAt().UTC().Format(time.RFC3339Nano), + UpdatedAt: msg.UpdatedAt().UTC().Format(time.RFC3339Nano), + } +} + +func chatSuccessBase() *commonv1.BaseResponse { + return &commonv1.BaseResponse{IsSuccess: true, Message: "success", StatusCode: "200"} +} + +func mapChatError(err error) error { + switch { + case errors.Is(err, chat.ErrConversationNotFound), errors.Is(err, chat.ErrMessageNotFound): + return status.Errorf(codes.NotFound, "%v", err) + case errors.Is(err, chat.ErrNotParticipant), errors.Is(err, chat.ErrNotAuthor), errors.Is(err, chat.ErrNotAdmin): + return status.Errorf(codes.PermissionDenied, "%v", err) + case errors.Is(err, chat.ErrDirectConversationFull), errors.Is(err, chat.ErrAlreadyParticipant): + return status.Errorf(codes.FailedPrecondition, "%v", err) + default: + return status.Errorf(codes.Internal, "internal error") + } +} diff --git a/services/iam/internal/delivery/grpc/presence_handler.go b/services/iam/internal/delivery/grpc/presence_handler.go new file mode 100644 index 00000000..3606b4dd --- /dev/null +++ b/services/iam/internal/delivery/grpc/presence_handler.go @@ -0,0 +1,62 @@ +package grpc + +import ( + "context" + + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonv1 "github.com/mutugading/goapps-backend/gen/common/v1" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" +) + +// PresenceHandler implements iamv1.PresenceServiceServer. +type PresenceHandler struct { + iamv1.UnimplementedPresenceServiceServer + presence *chatinfra.PresenceService +} + +// NewPresenceHandler constructs the handler. +func NewPresenceHandler(presence *chatinfra.PresenceService) *PresenceHandler { + return &PresenceHandler{presence: presence} +} + +// Heartbeat updates the caller's online presence. +func (h *PresenceHandler) Heartbeat(ctx context.Context, _ *iamv1.HeartbeatRequest) (*iamv1.HeartbeatResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + if err := h.presence.SetOnline(ctx, callerID); err != nil { + return nil, status.Errorf(codes.Internal, "heartbeat failed: %v", err) + } + return &iamv1.HeartbeatResponse{ + Base: &commonv1.BaseResponse{IsSuccess: true, Message: "success", StatusCode: "200"}, + }, nil +} + +// GetOnlineUsers returns which users are currently online. +func (h *PresenceHandler) GetOnlineUsers(ctx context.Context, req *iamv1.GetOnlineUsersRequest) (*iamv1.GetOnlineUsersResponse, error) { + userIDs := make([]uuid.UUID, 0, len(req.GetUserIds())) + for _, idStr := range req.GetUserIds() { + uid, err := uuid.Parse(idStr) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid user_id %q: %v", idStr, err) + } + userIDs = append(userIDs, uid) + } + onlineIDs, err := h.presence.GetOnlineUsers(ctx, userIDs) + if err != nil { + return nil, status.Errorf(codes.Internal, "get online users: %v", err) + } + strs := make([]string, len(onlineIDs)) + for i, uid := range onlineIDs { + strs[i] = uid.String() + } + return &iamv1.GetOnlineUsersResponse{ + Base: &commonv1.BaseResponse{IsSuccess: true, Message: "success", StatusCode: "200"}, + UserIds: strs, + }, nil +} diff --git a/services/iam/internal/domain/chat/entity.go b/services/iam/internal/domain/chat/entity.go new file mode 100644 index 00000000..ff8dc549 --- /dev/null +++ b/services/iam/internal/domain/chat/entity.go @@ -0,0 +1,338 @@ +package chat + +import ( + "time" + + "github.com/google/uuid" +) + +// Participant is a member of a conversation. +type Participant struct { + conversationID uuid.UUID + userID uuid.UUID + role Role + joinedAt time.Time + leftAt *time.Time + lastReadAt *time.Time +} + +// ReconstructParticipant rebuilds a Participant from persistence. +func ReconstructParticipant(conversationID, userID uuid.UUID, role Role, joinedAt time.Time, leftAt, lastReadAt *time.Time) *Participant { + return &Participant{ + conversationID: conversationID, userID: userID, role: role, + joinedAt: joinedAt, leftAt: leftAt, lastReadAt: lastReadAt, + } +} + +// UserID returns the participant user ID. +func (p *Participant) UserID() uuid.UUID { return p.userID } + +// Role returns the participant role. +func (p *Participant) Role() Role { return p.role } + +// JoinedAt returns when the participant joined. +func (p *Participant) JoinedAt() time.Time { return p.joinedAt } + +// LeftAt returns when the participant left, or nil. +func (p *Participant) LeftAt() *time.Time { return p.leftAt } + +// LastReadAt returns the last read timestamp, or nil. +func (p *Participant) LastReadAt() *time.Time { return p.lastReadAt } + +// IsActive returns true if the participant has not left. +func (p *Participant) IsActive() bool { return p.leftAt == nil } + +// Conversation is the aggregate root for a chat conversation. +type Conversation struct { + id uuid.UUID + convType Type + name string + avatarURL string + encryptionKey []byte + convKeyPlain []byte + participants []*Participant + createdBy string + createdAt time.Time + updatedAt time.Time + deletedAt *time.Time +} + +// NewDirectConversation creates a 1:1 conversation. +func NewDirectConversation(ownerID, peerID uuid.UUID, convKeyPlain, encryptionKey []byte) (*Conversation, error) { + now := time.Now().UTC() + conv := &Conversation{ + id: uuid.New(), + convType: TypeDirect, + encryptionKey: encryptionKey, + convKeyPlain: convKeyPlain, + createdBy: ownerID.String(), + createdAt: now, + updatedAt: now, + } + conv.participants = []*Participant{ + {conversationID: conv.id, userID: ownerID, role: RoleOwner, joinedAt: now}, + {conversationID: conv.id, userID: peerID, role: RoleMember, joinedAt: now}, + } + return conv, nil +} + +// NewGroupConversation creates a group conversation. +func NewGroupConversation(ownerID uuid.UUID, name string, memberIDs []uuid.UUID, convKeyPlain, encryptionKey []byte) (*Conversation, error) { + now := time.Now().UTC() + conv := &Conversation{ + id: uuid.New(), + convType: TypeGroup, + name: name, + encryptionKey: encryptionKey, + convKeyPlain: convKeyPlain, + createdBy: ownerID.String(), + createdAt: now, + updatedAt: now, + } + conv.participants = append(conv.participants, + &Participant{conversationID: conv.id, userID: ownerID, role: RoleOwner, joinedAt: now}, + ) + for _, mid := range memberIDs { + conv.participants = append(conv.participants, + &Participant{conversationID: conv.id, userID: mid, role: RoleMember, joinedAt: now}, + ) + } + return conv, nil +} + +// Reconstruct rebuilds a Conversation from persistence. +func Reconstruct( + id uuid.UUID, convType Type, name, avatarURL string, + encryptionKey []byte, createdBy string, + createdAt, updatedAt time.Time, deletedAt *time.Time, + participants []*Participant, +) *Conversation { + return &Conversation{ + id: id, convType: convType, name: name, avatarURL: avatarURL, + encryptionKey: encryptionKey, createdBy: createdBy, + createdAt: createdAt, updatedAt: updatedAt, deletedAt: deletedAt, + participants: participants, + } +} + +// ID returns the conversation ID. +func (c *Conversation) ID() uuid.UUID { return c.id } + +// Type returns the conversation type. +func (c *Conversation) Type() Type { return c.convType } + +// Name returns the conversation name. +func (c *Conversation) Name() string { return c.name } + +// AvatarURL returns the conversation avatar URL. +func (c *Conversation) AvatarURL() string { return c.avatarURL } + +// EncryptionKey returns the encrypted conversation key bytes. +func (c *Conversation) EncryptionKey() []byte { return c.encryptionKey } + +// ConvKeyPlain returns the decrypted conversation key. +func (c *Conversation) ConvKeyPlain() []byte { return c.convKeyPlain } + +// CreatedBy returns the creator identifier. +func (c *Conversation) CreatedBy() string { return c.createdBy } + +// CreatedAt returns the creation timestamp. +func (c *Conversation) CreatedAt() time.Time { return c.createdAt } + +// UpdatedAt returns the last update timestamp. +func (c *Conversation) UpdatedAt() time.Time { return c.updatedAt } + +// DeletedAt returns the deletion timestamp, or nil. +func (c *Conversation) DeletedAt() *time.Time { return c.deletedAt } + +// Participants returns all participants. +func (c *Conversation) Participants() []*Participant { return c.participants } + +// SetConvKeyPlain sets the decrypted conversation key. +func (c *Conversation) SetConvKeyPlain(key []byte) { c.convKeyPlain = key } + +// ActiveParticipantCount returns the number of non-left participants. +func (c *Conversation) ActiveParticipantCount() int { + count := 0 + for _, p := range c.participants { + if p.IsActive() { + count++ + } + } + return count +} + +// FindParticipant returns the participant with the given userID, or nil. +func (c *Conversation) FindParticipant(userID uuid.UUID) *Participant { + for _, p := range c.participants { + if p.userID == userID { + return p + } + } + return nil +} + +// AddParticipant adds a new active participant. +func (c *Conversation) AddParticipant(userID uuid.UUID, role Role) error { + if c.convType == TypeDirect && c.ActiveParticipantCount() >= 2 { + return ErrDirectConversationFull + } + for _, p := range c.participants { + if p.userID == userID && p.IsActive() { + return ErrAlreadyParticipant + } + } + now := time.Now().UTC() + c.participants = append(c.participants, &Participant{ + conversationID: c.id, userID: userID, role: role, joinedAt: now, + }) + c.updatedAt = now + return nil +} + +// UpdateGroup updates mutable group fields. +func (c *Conversation) UpdateGroup(name, avatarURL string) { + c.name = name + c.avatarURL = avatarURL + c.updatedAt = time.Now().UTC() +} + +// EditHistoryEntry is an immutable snapshot of a message before an edit. +type EditHistoryEntry struct { + historyID int64 + messageID uuid.UUID + bodyEncrypted []byte + editedBy uuid.UUID + editedAt time.Time +} + +// ReconstructEditHistory rebuilds an edit history entry from persistence. +func ReconstructEditHistory(historyID int64, messageID uuid.UUID, bodyEncrypted []byte, editedBy uuid.UUID, editedAt time.Time) *EditHistoryEntry { + return &EditHistoryEntry{historyID: historyID, messageID: messageID, bodyEncrypted: bodyEncrypted, editedBy: editedBy, editedAt: editedAt} +} + +// HistoryID returns the history entry ID. +func (e *EditHistoryEntry) HistoryID() int64 { return e.historyID } + +// MessageID returns the message this entry belongs to. +func (e *EditHistoryEntry) MessageID() uuid.UUID { return e.messageID } + +// BodyEncrypted returns the encrypted pre-edit body. +func (e *EditHistoryEntry) BodyEncrypted() []byte { return e.bodyEncrypted } + +// EditedBy returns the user who made the edit. +func (e *EditHistoryEntry) EditedBy() uuid.UUID { return e.editedBy } + +// EditedAt returns when the edit was made. +func (e *EditHistoryEntry) EditedAt() time.Time { return e.editedAt } + +// ReadReceipt records that a user has read a message. +type ReadReceipt struct { + messageID uuid.UUID + userID uuid.UUID + readAt time.Time +} + +// MessageID returns the message ID. +func (r *ReadReceipt) MessageID() uuid.UUID { return r.messageID } + +// UserID returns the user ID. +func (r *ReadReceipt) UserID() uuid.UUID { return r.userID } + +// ReadAt returns the read timestamp. +func (r *ReadReceipt) ReadAt() time.Time { return r.readAt } + +// NewReadReceipt constructs a ReadReceipt from persistence data. +func NewReadReceipt(messageID, userID uuid.UUID, readAt time.Time) *ReadReceipt { + return &ReadReceipt{messageID: messageID, userID: userID, readAt: readAt} +} + +// Message is the aggregate root for a chat message. +type Message struct { + messageID uuid.UUID + conversationID uuid.UUID + senderUserID uuid.UUID + bodyEncrypted []byte + bodyPlainEncrypted []byte + isEdited bool + isDeleted bool + replyToID uuid.UUID + readReceipts []*ReadReceipt + createdAt time.Time + updatedAt time.Time +} + +// NewMessage creates a new message with encrypted bodies. +func NewMessage(conversationID, senderID uuid.UUID, bodyEncrypted, bodyPlainEncrypted []byte, replyToID uuid.UUID) *Message { + now := time.Now().UTC() + return &Message{ + messageID: uuid.New(), conversationID: conversationID, senderUserID: senderID, + bodyEncrypted: bodyEncrypted, bodyPlainEncrypted: bodyPlainEncrypted, + replyToID: replyToID, createdAt: now, updatedAt: now, + } +} + +// ReconstructMessage rebuilds a Message from persistence. +func ReconstructMessage( + messageID, conversationID, senderID uuid.UUID, + bodyEncrypted, bodyPlainEncrypted []byte, + isEdited, isDeleted bool, replyToID uuid.UUID, + receipts []*ReadReceipt, + createdAt, updatedAt time.Time, +) *Message { + return &Message{ + messageID: messageID, conversationID: conversationID, senderUserID: senderID, + bodyEncrypted: bodyEncrypted, bodyPlainEncrypted: bodyPlainEncrypted, + isEdited: isEdited, isDeleted: isDeleted, replyToID: replyToID, + readReceipts: receipts, createdAt: createdAt, updatedAt: updatedAt, + } +} + +// MessageID returns the message ID. +func (m *Message) MessageID() uuid.UUID { return m.messageID } + +// ConversationID returns the conversation ID. +func (m *Message) ConversationID() uuid.UUID { return m.conversationID } + +// SenderUserID returns the sender user ID. +func (m *Message) SenderUserID() uuid.UUID { return m.senderUserID } + +// BodyEncrypted returns the encrypted message body. +func (m *Message) BodyEncrypted() []byte { return m.bodyEncrypted } + +// BodyPlainEncrypted returns the encrypted plaintext body. +func (m *Message) BodyPlainEncrypted() []byte { return m.bodyPlainEncrypted } + +// IsEdited returns true if the message has been edited. +func (m *Message) IsEdited() bool { return m.isEdited } + +// IsDeleted returns true if the message has been soft-deleted. +func (m *Message) IsDeleted() bool { return m.isDeleted } + +// ReplyToID returns the reply-to message ID, or uuid.Nil if none. +func (m *Message) ReplyToID() uuid.UUID { return m.replyToID } + +// ReadReceipts returns all read receipts for this message. +func (m *Message) ReadReceipts() []*ReadReceipt { return m.readReceipts } + +// CreatedAt returns the creation timestamp. +func (m *Message) CreatedAt() time.Time { return m.createdAt } + +// UpdatedAt returns the last update timestamp. +func (m *Message) UpdatedAt() time.Time { return m.updatedAt } + +// Edit replaces the body and returns the OLD bodyEncrypted for history. +func (m *Message) Edit(newBodyEncrypted, newBodyPlainEncrypted []byte) []byte { + old := m.bodyEncrypted + m.bodyEncrypted = newBodyEncrypted + m.bodyPlainEncrypted = newBodyPlainEncrypted + m.isEdited = true + m.updatedAt = time.Now().UTC() + return old +} + +// SoftDelete marks the message as deleted. +func (m *Message) SoftDelete() { + m.isDeleted = true + m.updatedAt = time.Now().UTC() +} diff --git a/services/iam/internal/domain/chat/entity_test.go b/services/iam/internal/domain/chat/entity_test.go new file mode 100644 index 00000000..4b38e0be --- /dev/null +++ b/services/iam/internal/domain/chat/entity_test.go @@ -0,0 +1,62 @@ +package chat_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +func TestConversation_NewDirect(t *testing.T) { + ownerID := uuid.New() + peerID := uuid.New() + convKey := make([]byte, 32) + encKey := []byte("encrypted-key-bytes") + + conv, err := chat.NewDirectConversation(ownerID, peerID, convKey, encKey) + require.NoError(t, err) + assert.Equal(t, chat.TypeDirect, conv.Type()) + assert.Len(t, conv.Participants(), 2) + assert.NotEqual(t, uuid.Nil, conv.ID()) +} + +func TestConversation_DirectParticipantLimit(t *testing.T) { + ownerID := uuid.New() + peerID := uuid.New() + conv, err := chat.NewDirectConversation(ownerID, peerID, make([]byte, 32), []byte("enc")) + require.NoError(t, err) + + err = conv.AddParticipant(uuid.New(), chat.RoleMember) + assert.ErrorIs(t, err, chat.ErrDirectConversationFull) +} + +func TestMessage_SoftDelete(t *testing.T) { + msg := chat.ReconstructMessage( + uuid.New(), uuid.New(), uuid.New(), + []byte("enc"), []byte("plain-enc"), + false, false, uuid.Nil, + nil, time.Now(), time.Now(), + ) + assert.False(t, msg.IsDeleted()) + msg.SoftDelete() + assert.True(t, msg.IsDeleted()) +} + +func TestMessage_Edit(t *testing.T) { + msg := chat.ReconstructMessage( + uuid.New(), uuid.New(), uuid.New(), + []byte("enc"), []byte("plain-enc"), + false, false, uuid.Nil, + nil, time.Now(), time.Now(), + ) + newEnc := []byte("new-encrypted-body") + newPlain := []byte("new-plain-enc") + old := msg.Edit(newEnc, newPlain) + assert.Equal(t, []byte("enc"), old) + assert.True(t, msg.IsEdited()) + assert.Equal(t, newEnc, msg.BodyEncrypted()) +} diff --git a/services/iam/internal/domain/chat/errors.go b/services/iam/internal/domain/chat/errors.go new file mode 100644 index 00000000..1e5b4187 --- /dev/null +++ b/services/iam/internal/domain/chat/errors.go @@ -0,0 +1,30 @@ +package chat + +import "errors" + +// ErrConversationNotFound indicates the conversation does not exist. +var ErrConversationNotFound = errors.New("conversation not found") + +// ErrMessageNotFound indicates the message does not exist. +var ErrMessageNotFound = errors.New("message not found") + +// ErrNotParticipant indicates the user is not a participant. +var ErrNotParticipant = errors.New("user is not a participant of this conversation") + +// ErrNotAuthor indicates the user is not the message author. +var ErrNotAuthor = errors.New("user is not the author of this message") + +// ErrNotAdmin indicates the user lacks admin role. +var ErrNotAdmin = errors.New("user is not an admin of this conversation") + +// ErrDirectConversationFull indicates a direct conversation already has 2 participants. +var ErrDirectConversationFull = errors.New("direct conversation cannot have more than 2 participants") + +// ErrAlreadyParticipant indicates the user is already in the conversation. +var ErrAlreadyParticipant = errors.New("user is already a participant") + +// ErrMessageDeleted indicates the message has been soft-deleted. +var ErrMessageDeleted = errors.New("message has been deleted") + +// ErrParticipantLeft indicates the participant already left. +var ErrParticipantLeft = errors.New("participant has already left the conversation") diff --git a/services/iam/internal/domain/chat/repository.go b/services/iam/internal/domain/chat/repository.go new file mode 100644 index 00000000..dea1bc5a --- /dev/null +++ b/services/iam/internal/domain/chat/repository.go @@ -0,0 +1,71 @@ +package chat + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// ConversationRepository handles persistence for Conversation aggregates. +type ConversationRepository interface { + // Create inserts a new conversation with its initial participants. + Create(ctx context.Context, conv *Conversation) error + + // GetByID returns a conversation with all active participants. + GetByID(ctx context.Context, id uuid.UUID) (*Conversation, error) + + // FindDirect finds an existing DIRECT conversation between two users. + FindDirect(ctx context.Context, userA, userB uuid.UUID) (*Conversation, error) + + // ListByUserID returns conversations the user participates in, ordered by last message. + ListByUserID(ctx context.Context, userID uuid.UUID, page, pageSize int) ([]*Conversation, int64, error) + + // UpdateGroup persists name and avatar changes. + UpdateGroup(ctx context.Context, conv *Conversation) error + + // AddParticipants inserts new participant rows. + AddParticipants(ctx context.Context, conversationID uuid.UUID, participants []*Participant) error + + // RemoveParticipant sets left_at for a participant. + RemoveParticipant(ctx context.Context, conversationID, userID uuid.UUID) error + + // UpdateLastReadAt updates chat_participant.last_read_at for a user. + UpdateLastReadAt(ctx context.Context, conversationID, userID uuid.UUID, at time.Time) error +} + +// MessageRepository handles persistence for Message aggregates. +type MessageRepository interface { + // Create inserts a new message. + Create(ctx context.Context, msg *Message) error + + // GetByID returns a message with its read receipts. + GetByID(ctx context.Context, id uuid.UUID) (*Message, error) + + // ListByConversation returns messages using cursor-based pagination (newest first). + ListByConversation(ctx context.Context, conversationID uuid.UUID, pageSize int, beforeCursor string) ([]*Message, string, bool, error) + + // UpdateBody persists body changes after an edit. + UpdateBody(ctx context.Context, msg *Message) error + + // MarkDeleted sets is_deleted to true. + MarkDeleted(ctx context.Context, id uuid.UUID) error + + // SaveEditHistory inserts a pre-edit snapshot. + SaveEditHistory(ctx context.Context, entry *EditHistoryEntry) error + + // GetEditHistory returns all edit history for a message, newest first. + GetEditHistory(ctx context.Context, messageID uuid.UUID) ([]*EditHistoryEntry, error) +} + +// ReadReceiptRepository handles read receipts. +type ReadReceiptRepository interface { + // Upsert inserts or ignores a read receipt (idempotent). + Upsert(ctx context.Context, messageID, userID uuid.UUID) error + + // ListByMessage returns all receipts for a message. + ListByMessage(ctx context.Context, messageID uuid.UUID) ([]*ReadReceipt, error) + + // UpsertBulk marks multiple messages as read for a user. + UpsertBulk(ctx context.Context, messageIDs []uuid.UUID, userID uuid.UUID) error +} diff --git a/services/iam/internal/domain/chat/value_objects.go b/services/iam/internal/domain/chat/value_objects.go new file mode 100644 index 00000000..8ac5f281 --- /dev/null +++ b/services/iam/internal/domain/chat/value_objects.go @@ -0,0 +1,54 @@ +package chat + +// Type represents the conversation type. +type Type string + +const ( + // TypeDirect is a 1:1 direct message conversation. + TypeDirect Type = "DIRECT" + // TypeGroup is a multi-user group conversation. + TypeGroup Type = "GROUP" +) + +// String returns the string representation. +func (t Type) String() string { return string(t) } + +// ParseType parses a string into a Type. +func ParseType(s string) (Type, error) { + switch Type(s) { + case TypeDirect, TypeGroup: + return Type(s), nil + default: + return "", ErrConversationNotFound + } +} + +// Role represents a participant's role in a conversation. +type Role string + +const ( + // RoleOwner is the conversation creator. + RoleOwner Role = "OWNER" + // RoleAdmin can manage participants. + RoleAdmin Role = "ADMIN" + // RoleMember is a regular participant. + RoleMember Role = "MEMBER" +) + +// String returns the string representation. +func (r Role) String() string { return string(r) } + +// ParseRole parses a string into a Role. +func ParseRole(s string) (Role, error) { + switch Role(s) { + case RoleOwner, RoleAdmin, RoleMember: + return Role(s), nil + default: + return "", ErrNotParticipant + } +} + +// IsAdminOrOwner returns true if the role allows admin actions. +func (r Role) IsAdminOrOwner() bool { + return r == RoleOwner || r == RoleAdmin +} diff --git a/services/iam/internal/infrastructure/chat/broadcaster.go b/services/iam/internal/infrastructure/chat/broadcaster.go new file mode 100644 index 00000000..dd061c4d --- /dev/null +++ b/services/iam/internal/infrastructure/chat/broadcaster.go @@ -0,0 +1,270 @@ +// Package chat provides in-memory pub/sub fan-out and Redis-backed presence +// tracking for the chat stream. +// +// Each authenticated user can have multiple subscribers (open SSE streams in +// different tabs). Publish fans out to all of a recipient's in-memory subscribers. +// +// When a Redis client is provided (via NewRedisBroadcaster), events are also +// published to a Redis pub/sub channel so that other IAM pods pick them up. +// This allows HPA to scale IAM to multiple replicas without losing SSE events. +// Same-pod events are deduplicated via the originating pod ID embedded in the +// Redis message, so subscribers receive each event exactly once. +package chat + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" +) + +const chatChannelPrefix = "iam:chat:" + +// Event is a chat event fanned out to subscribers. Payload carries the +// JSON-encoded ChatEvent proto so the broadcaster stays decoupled from the +// chat domain model. +type Event struct { + EventID string + UserID uuid.UUID + Payload []byte +} + +// Broadcaster is the in-memory event bus. +// +// For multi-replica IAM deployments, construct it with NewRedisBroadcaster so +// that events published on one pod are received by all pods via Redis pub/sub. +// For single-replica deployments, NewBroadcaster (in-memory only) is sufficient. +type Broadcaster struct { + mu sync.RWMutex + subs map[uuid.UUID]map[chan *Event]struct{} + rdb *redis.Client // nil → in-memory only + selfPodID string +} + +// NewBroadcaster returns a fresh in-memory-only Broadcaster. +func NewBroadcaster() *Broadcaster { + return &Broadcaster{ + subs: make(map[uuid.UUID]map[chan *Event]struct{}), + } +} + +// NewRedisBroadcaster returns a Broadcaster backed by Redis pub/sub for +// cross-pod fan-out. Pass the same *redis.Client used for the session cache. +// If rdb is nil, falls back to in-memory behavior. +func NewRedisBroadcaster(rdb *redis.Client) *Broadcaster { + return &Broadcaster{ + subs: make(map[uuid.UUID]map[chan *Event]struct{}), + rdb: rdb, + selfPodID: resolvePodID(), + } +} + +// resolvePodID returns the K8s pod name (os.Hostname) or a random UUID fallback. +func resolvePodID() string { + h, err := os.Hostname() + if err != nil || h == "" { + return uuid.New().String() + } + return h +} + +// Subscribe registers a new subscriber for recipient. +// Returns the receive channel and an unsubscribe function the caller must invoke +// (typically via defer) when the stream ends. +// +// If a Redis client is configured, a background goroutine bridges events from +// other pods into the returned channel. Events from this pod are excluded to +// prevent duplicates (they arrive via the in-memory path already). +func (b *Broadcaster) Subscribe(recipient uuid.UUID) (<-chan *Event, func()) { + const bufferSize = 16 + ch := make(chan *Event, bufferSize) + + b.mu.Lock() + if _, ok := b.subs[recipient]; !ok { + b.subs[recipient] = make(map[chan *Event]struct{}) + } + b.subs[recipient][ch] = struct{}{} + b.mu.Unlock() + + unsubMem := b.makeUnsubFunc(recipient, ch) + + if b.rdb == nil { + return ch, unsubMem + } + + cancelRedis := b.bridgeRedis(recipient, ch) + return ch, func() { + cancelRedis() + unsubMem() + } +} + +// makeUnsubFunc returns a function that removes ch from the in-memory subscriber +// set for recipient and closes the channel. +func (b *Broadcaster) makeUnsubFunc(recipient uuid.UUID, ch chan *Event) func() { + return func() { + b.mu.Lock() + defer b.mu.Unlock() + set, ok := b.subs[recipient] + if !ok { + return + } + delete(set, ch) + if len(set) == 0 { + delete(b.subs, recipient) + } + close(ch) + } +} + +// Publish fans out an event to all in-memory subscribers of its recipient, +// then publishes to the Redis channel so other pods can forward it to their +// in-memory subscribers. Non-blocking: slow consumers drop the event. +func (b *Broadcaster) Publish(e *Event) { + if e == nil { + return + } + b.publishInMemory(e) + if b.rdb != nil { + b.publishRedis(e) + } +} + +// PublishToConversation fans out the same event payload to every participant +// of a conversation. eventID and payload are shared; only the recipient +// differs per participant. +func (b *Broadcaster) PublishToConversation(participantIDs []uuid.UUID, eventID string, payload []byte) { + for _, userID := range participantIDs { + b.Publish(&Event{ + EventID: eventID, + UserID: userID, + Payload: payload, + }) + } +} + +func (b *Broadcaster) publishInMemory(e *Event) { + b.mu.RLock() + defer b.mu.RUnlock() + set, ok := b.subs[e.UserID] + if !ok { + return + } + for ch := range set { + select { + case ch <- e: + default: + // drop — subscriber will resync via DB on reconnect + } + } +} + +func (b *Broadcaster) publishRedis(e *Event) { + payload, err := serializeEvent(e, b.selfPodID) + if err != nil { + log.Warn().Err(err).Msg("broadcaster: failed to serialize chat event for Redis pub/sub") + return + } + channel := chatChannelPrefix + e.UserID.String() + if err := b.rdb.Publish(context.Background(), channel, payload).Err(); err != nil { + log.Warn().Err(err).Msg("broadcaster: failed to publish chat event to Redis") + } +} + +// bridgeRedis subscribes to the Redis pub/sub channel for recipient and +// forwards events from other pods into ch. Returns a cancel function that +// terminates the bridge goroutine. +func (b *Broadcaster) bridgeRedis(recipient uuid.UUID, ch chan *Event) context.CancelFunc { + ctx, cancel := context.WithCancel(context.Background()) + channel := chatChannelPrefix + recipient.String() + pubsub := b.rdb.Subscribe(ctx, channel) + + go func() { + defer func() { + if err := pubsub.Close(); err != nil { + log.Warn().Err(err).Msg("broadcaster: Redis pubsub close error") + } + }() + msgCh := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-msgCh: + if !ok { + return + } + b.handleRedisMessage(msg.Payload, ch) + } + } + }() + + return cancel +} + +func (b *Broadcaster) handleRedisMessage(payload string, ch chan *Event) { + e, podID, err := deserializeEvent(payload) + if err != nil { + log.Warn().Err(err).Msg("broadcaster: failed to deserialize Redis chat event") + return + } + if podID == b.selfPodID { + return // already delivered via in-memory fan-out + } + select { + case ch <- e: + default: + // drop — subscriber will resync via DB on reconnect + } +} + +// SubscriberCount returns the total number of in-memory subscribers for recipient. +func (b *Broadcaster) SubscriberCount(recipient uuid.UUID) int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subs[recipient]) +} + +// redisMsg is the JSON wire format used for Redis pub/sub cross-pod fan-out. +type redisMsg struct { + PodID string `json:"pod"` + EventID string `json:"eid"` + UserID string `json:"uid"` + Payload []byte `json:"p"` +} + +func serializeEvent(e *Event, podID string) (string, error) { + msg := redisMsg{ + PodID: podID, + EventID: e.EventID, + UserID: e.UserID.String(), + Payload: e.Payload, + } + b, err := json.Marshal(msg) + if err != nil { + return "", fmt.Errorf("marshal chat event: %w", err) + } + return string(b), nil +} + +func deserializeEvent(payload string) (*Event, string, error) { + var msg redisMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + return nil, "", fmt.Errorf("unmarshal chat event: %w", err) + } + userID, err := uuid.Parse(msg.UserID) + if err != nil { + return nil, "", fmt.Errorf("parse user id: %w", err) + } + e := &Event{ + EventID: msg.EventID, + UserID: userID, + Payload: msg.Payload, + } + return e, msg.PodID, nil +} diff --git a/services/iam/internal/infrastructure/chat/broadcaster_test.go b/services/iam/internal/infrastructure/chat/broadcaster_test.go new file mode 100644 index 00000000..2d23bd92 --- /dev/null +++ b/services/iam/internal/infrastructure/chat/broadcaster_test.go @@ -0,0 +1,71 @@ +package chat_test + +import ( + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" +) + +func TestBroadcaster_PublishReceive(t *testing.T) { + t.Parallel() + b := chatinfra.NewBroadcaster() + user := uuid.New() + ch, unsub := b.Subscribe(user) + defer unsub() + + ev := &chatinfra.Event{ + EventID: uuid.New().String(), + UserID: user, + Payload: []byte(`{"type":"message.created"}`), + } + b.Publish(ev) + + select { + case got := <-ch: + assert.Equal(t, ev.EventID, got.EventID) + assert.Equal(t, ev.UserID, got.UserID) + assert.Equal(t, ev.Payload, got.Payload) + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for chat event") + } +} + +func TestBroadcaster_MultipleSubscribers(t *testing.T) { + t.Parallel() + b := chatinfra.NewBroadcaster() + user := uuid.New() + + ch1, unsub1 := b.Subscribe(user) + defer unsub1() + ch2, unsub2 := b.Subscribe(user) + defer unsub2() + + assert.Equal(t, 2, b.SubscriberCount(user)) + + ev := &chatinfra.Event{ + EventID: uuid.New().String(), + UserID: user, + Payload: []byte(`{"type":"message.created"}`), + } + b.Publish(ev) + + var wg sync.WaitGroup + wg.Add(2) + for _, ch := range []<-chan *chatinfra.Event{ch1, ch2} { + go func(ch <-chan *chatinfra.Event) { + defer wg.Done() + select { + case got := <-ch: + assert.Equal(t, ev.EventID, got.EventID) + case <-time.After(100 * time.Millisecond): + t.Error("subscriber didn't receive event") + } + }(ch) + } + wg.Wait() +} diff --git a/services/iam/internal/infrastructure/chat/presence.go b/services/iam/internal/infrastructure/chat/presence.go new file mode 100644 index 00000000..36e48e23 --- /dev/null +++ b/services/iam/internal/infrastructure/chat/presence.go @@ -0,0 +1,140 @@ +package chat + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +const ( + presenceKeyPrefix = "iam:presence:" + typingKeyPrefix = "iam:typing:" + presenceTTL = 90 * time.Second + typingTTL = 5 * time.Second +) + +// PresenceService tracks user online status and per-conversation typing +// indicators using short-TTL Redis keys. Presence/typing is best-effort: +// callers refresh via periodic heartbeats, and keys expire naturally when a +// client disconnects without an explicit "offline" signal. +type PresenceService struct { + rdb *redis.Client +} + +// NewPresenceService returns a PresenceService backed by rdb. +func NewPresenceService(rdb *redis.Client) *PresenceService { + return &PresenceService{rdb: rdb} +} + +func presenceKey(userID uuid.UUID) string { + return presenceKeyPrefix + userID.String() +} + +func typingKey(convID, userID uuid.UUID) string { + return typingKeyPrefix + convID.String() + ":" + userID.String() +} + +// SetOnline marks userID as online for presenceTTL. Callers should call this +// periodically (heartbeat) while the user has an active chat connection. +func (p *PresenceService) SetOnline(ctx context.Context, userID uuid.UUID) error { + if err := p.rdb.Set(ctx, presenceKey(userID), "1", presenceTTL).Err(); err != nil { + return fmt.Errorf("presence: set online for user %s: %w", userID, err) + } + return nil +} + +// IsOnline reports whether userID has an unexpired presence key. +func (p *PresenceService) IsOnline(ctx context.Context, userID uuid.UUID) (bool, error) { + n, err := p.rdb.Exists(ctx, presenceKey(userID)).Result() + if err != nil { + return false, fmt.Errorf("presence: check online for user %s: %w", userID, err) + } + return n > 0, nil +} + +// GetOnlineUsers returns the subset of userIDs that are currently online. If +// userIDs is empty, all currently online users are returned instead (found by +// scanning the presence key namespace). +func (p *PresenceService) GetOnlineUsers(ctx context.Context, userIDs []uuid.UUID) ([]uuid.UUID, error) { + if len(userIDs) == 0 { + return p.getAllOnlineUsers(ctx) + } + + keys := make([]string, len(userIDs)) + for i, id := range userIDs { + keys[i] = presenceKey(id) + } + + vals, err := p.rdb.MGet(ctx, keys...).Result() + if err != nil { + return nil, fmt.Errorf("presence: mget online users: %w", err) + } + + online := make([]uuid.UUID, 0, len(userIDs)) + for i, v := range vals { + if v != nil { + online = append(online, userIDs[i]) + } + } + return online, nil +} + +func (p *PresenceService) getAllOnlineUsers(ctx context.Context) ([]uuid.UUID, error) { + keys, err := p.rdb.Keys(ctx, presenceKeyPrefix+"*").Result() + if err != nil { + return nil, fmt.Errorf("presence: scan online users: %w", err) + } + + online := make([]uuid.UUID, 0, len(keys)) + for _, k := range keys { + idStr := strings.TrimPrefix(k, presenceKeyPrefix) + id, err := uuid.Parse(idStr) + if err != nil { + continue + } + online = append(online, id) + } + return online, nil +} + +// SetTyping marks userID as typing in convID for typingTTL, or immediately +// clears the typing indicator when isTyping is false. +func (p *PresenceService) SetTyping(ctx context.Context, convID, userID uuid.UUID, isTyping bool) error { + key := typingKey(convID, userID) + if !isTyping { + if err := p.rdb.Del(ctx, key).Err(); err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("presence: clear typing for user %s in conversation %s: %w", userID, convID, err) + } + return nil + } + if err := p.rdb.Set(ctx, key, "1", typingTTL).Err(); err != nil { + return fmt.Errorf("presence: set typing for user %s in conversation %s: %w", userID, convID, err) + } + return nil +} + +// GetTypingUsers returns the users currently typing in convID. +func (p *PresenceService) GetTypingUsers(ctx context.Context, convID uuid.UUID) ([]uuid.UUID, error) { + pattern := typingKeyPrefix + convID.String() + ":*" + keys, err := p.rdb.Keys(ctx, pattern).Result() + if err != nil { + return nil, fmt.Errorf("presence: scan typing users for conversation %s: %w", convID, err) + } + + prefix := typingKeyPrefix + convID.String() + ":" + typing := make([]uuid.UUID, 0, len(keys)) + for _, k := range keys { + idStr := strings.TrimPrefix(k, prefix) + id, err := uuid.Parse(idStr) + if err != nil { + continue + } + typing = append(typing, id) + } + return typing, nil +} diff --git a/services/iam/internal/infrastructure/crypto/message_encryptor.go b/services/iam/internal/infrastructure/crypto/message_encryptor.go new file mode 100644 index 00000000..290b0542 --- /dev/null +++ b/services/iam/internal/infrastructure/crypto/message_encryptor.go @@ -0,0 +1,109 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "fmt" + "io" +) + +// Encryptor handles AES-256-GCM encryption for chat messages. +type Encryptor struct { + masterGCM cipher.AEAD +} + +// NewEncryptor creates an Encryptor from a 32-byte master key. +func NewEncryptor(masterKey []byte) (*Encryptor, error) { + if len(masterKey) != 32 { + return nil, fmt.Errorf("crypto: master key must be 32 bytes, got %d", len(masterKey)) + } + block, err := aes.NewCipher(masterKey) + if err != nil { + return nil, fmt.Errorf("crypto: create master cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: create master GCM: %w", err) + } + return &Encryptor{masterGCM: gcm}, nil +} + +// GenerateConversationKey generates a random 32-byte AES key for a new conversation. +func (e *Encryptor) GenerateConversationKey() ([]byte, error) { + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, fmt.Errorf("crypto: generate conv key: %w", err) + } + return key, nil +} + +// EncryptConversationKey encrypts a conversation key with the master key. +func (e *Encryptor) EncryptConversationKey(convKey []byte) ([]byte, error) { + return e.seal(e.masterGCM, convKey) +} + +// DecryptConversationKey decrypts a stored conversation key using the master key. +func (e *Encryptor) DecryptConversationKey(encConvKey []byte) ([]byte, error) { + return e.open(e.masterGCM, encConvKey) +} + +// EncryptMessage encrypts a plaintext message body with the given conversation key. +func (e *Encryptor) EncryptMessage(convKey []byte, plaintext string) ([]byte, error) { + gcm, err := e.convGCM(convKey) + if err != nil { + return nil, err + } + return e.seal(gcm, []byte(plaintext)) +} + +// DecryptMessage decrypts a stored message ciphertext using the conversation key. +func (e *Encryptor) DecryptMessage(convKey, ciphertext []byte) (string, error) { + gcm, err := e.convGCM(convKey) + if err != nil { + return "", err + } + plain, err := e.open(gcm, ciphertext) + if err != nil { + return "", err + } + return string(plain), nil +} + +func (e *Encryptor) convGCM(convKey []byte) (cipher.AEAD, error) { + if len(convKey) != 32 { + return nil, fmt.Errorf("crypto: conv key must be 32 bytes, got %d", len(convKey)) + } + block, err := aes.NewCipher(convKey) + if err != nil { + return nil, fmt.Errorf("crypto: create conv cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: create conv GCM: %w", err) + } + return gcm, nil +} + +func (e *Encryptor) seal(gcm cipher.AEAD, plaintext []byte) ([]byte, error) { + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("crypto: generate nonce: %w", err) + } + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +func (e *Encryptor) open(gcm cipher.AEAD, data []byte) ([]byte, error) { + ns := gcm.NonceSize() + if len(data) < ns { + return nil, errors.New("crypto: ciphertext too short") + } + nonce, ct := data[:ns], data[ns:] + plain, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("crypto: decrypt failed (tampered?): %w", err) + } + return plain, nil +} diff --git a/services/iam/internal/infrastructure/crypto/message_encryptor_test.go b/services/iam/internal/infrastructure/crypto/message_encryptor_test.go new file mode 100644 index 00000000..9e5b64aa --- /dev/null +++ b/services/iam/internal/infrastructure/crypto/message_encryptor_test.go @@ -0,0 +1,58 @@ +package crypto_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" +) + +func TestEncryptor_RoundTrip(t *testing.T) { + masterKey := make([]byte, 32) + enc, err := crypto.NewEncryptor(masterKey) + require.NoError(t, err) + + convKey, err := enc.GenerateConversationKey() + require.NoError(t, err) + assert.Len(t, convKey, 32) + + encConvKey, err := enc.EncryptConversationKey(convKey) + require.NoError(t, err) + + decConvKey, err := enc.DecryptConversationKey(encConvKey) + require.NoError(t, err) + assert.Equal(t, convKey, decConvKey) + + ciphertext, err := enc.EncryptMessage(convKey, "hello world") + require.NoError(t, err) + assert.NotEmpty(t, ciphertext) + assert.NotContains(t, string(ciphertext), "hello world") + + plaintext, err := enc.DecryptMessage(convKey, ciphertext) + require.NoError(t, err) + assert.Equal(t, "hello world", plaintext) +} + +func TestEncryptor_TamperDetection(t *testing.T) { + masterKey := make([]byte, 32) + enc, err := crypto.NewEncryptor(masterKey) + require.NoError(t, err) + + convKey, err := enc.GenerateConversationKey() + require.NoError(t, err) + + ct, err := enc.EncryptMessage(convKey, "secret") + require.NoError(t, err) + + ct[len(ct)-1] ^= 0xFF + + _, err = enc.DecryptMessage(convKey, ct) + assert.Error(t, err, "tampered ciphertext must fail") +} + +func TestEncryptor_InvalidMasterKey(t *testing.T) { + _, err := crypto.NewEncryptor([]byte("too-short")) + assert.Error(t, err) +} diff --git a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go new file mode 100644 index 00000000..34e1e3a0 --- /dev/null +++ b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go @@ -0,0 +1,259 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// ChatConversationRepository implements chat.ConversationRepository. +type ChatConversationRepository struct { + db *DB +} + +// NewChatConversationRepository constructs the repo. +func NewChatConversationRepository(db *DB) *ChatConversationRepository { + return &ChatConversationRepository{db: db} +} + +// Create inserts a new conversation with its initial participants. +func (r *ChatConversationRepository) Create(ctx context.Context, conv *chat.Conversation) error { + return r.db.Transaction(ctx, func(tx *sql.Tx) error { + const convQ = ` + INSERT INTO chat_conversation + (conversation_id, type, name, avatar_url, encryption_key, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)` + if _, err := tx.ExecContext(ctx, convQ, + conv.ID(), conv.Type().String(), conv.Name(), conv.AvatarURL(), + conv.EncryptionKey(), conv.CreatedBy(), conv.CreatedAt(), conv.UpdatedAt(), + ); err != nil { + return fmt.Errorf("insert conversation: %w", err) + } + + const partQ = ` + INSERT INTO chat_participant (conversation_id, user_id, role, joined_at) + VALUES ($1,$2,$3,$4)` + for _, p := range conv.Participants() { + if _, err := tx.ExecContext(ctx, partQ, + conv.ID(), p.UserID(), p.Role().String(), p.JoinedAt(), + ); err != nil { + return fmt.Errorf("insert participant: %w", err) + } + } + return nil + }) +} + +// GetByID returns a conversation with all active participants. +func (r *ChatConversationRepository) GetByID(ctx context.Context, id uuid.UUID) (*chat.Conversation, error) { + const q = ` + SELECT conversation_id, type, name, avatar_url, encryption_key, + created_by, created_at, updated_at, deleted_at + FROM chat_conversation + WHERE conversation_id = $1 AND deleted_at IS NULL` + + conv, err := r.scanConversation(r.db.QueryRowContext(ctx, q, id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, chat.ErrConversationNotFound + } + return nil, fmt.Errorf("chat conv repo: get by id: %w", err) + } + + participants, err := r.loadParticipants(ctx, id) + if err != nil { + return nil, err + } + return chat.Reconstruct( + conv.ID(), conv.Type(), conv.Name(), conv.AvatarURL(), + conv.EncryptionKey(), conv.CreatedBy(), + conv.CreatedAt(), conv.UpdatedAt(), conv.DeletedAt(), + participants, + ), nil +} + +// FindDirect finds an existing DIRECT conversation between two users. +func (r *ChatConversationRepository) FindDirect(ctx context.Context, userA, userB uuid.UUID) (*chat.Conversation, error) { + const q = ` + SELECT cc.conversation_id + FROM chat_conversation cc + JOIN chat_participant pa ON pa.conversation_id = cc.conversation_id AND pa.user_id = $1 AND pa.left_at IS NULL + JOIN chat_participant pb ON pb.conversation_id = cc.conversation_id AND pb.user_id = $2 AND pb.left_at IS NULL + WHERE cc.type = 'DIRECT' AND cc.deleted_at IS NULL + LIMIT 1` + + var convID uuid.UUID + err := r.db.QueryRowContext(ctx, q, userA, userB).Scan(&convID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // nil,nil means not found + } + if err != nil { + return nil, fmt.Errorf("chat conv repo: find direct: %w", err) + } + return r.GetByID(ctx, convID) +} + +// ListByUserID returns conversations the user participates in. +func (r *ChatConversationRepository) ListByUserID(ctx context.Context, userID uuid.UUID, page, pageSize int) ([]*chat.Conversation, int64, error) { + offset := (page - 1) * pageSize + + const listQ = ` + SELECT cc.conversation_id + FROM chat_conversation cc + JOIN chat_participant cp ON cp.conversation_id = cc.conversation_id + AND cp.user_id = $1 AND cp.left_at IS NULL + WHERE cc.deleted_at IS NULL + ORDER BY cc.updated_at DESC + LIMIT $2 OFFSET $3` + + rows, err := r.db.QueryContext(ctx, listQ, userID, pageSize, offset) + if err != nil { + return nil, 0, fmt.Errorf("chat conv repo: list: %w", err) + } + defer rows.Close() + + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, 0, fmt.Errorf("chat conv repo: scan id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("chat conv repo: rows err: %w", err) + } + + const countQ = ` + SELECT COUNT(*) FROM chat_conversation cc + JOIN chat_participant cp ON cp.conversation_id = cc.conversation_id + AND cp.user_id = $1 AND cp.left_at IS NULL + WHERE cc.deleted_at IS NULL` + + var total int64 + if err := r.db.QueryRowContext(ctx, countQ, userID).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("chat conv repo: count: %w", err) + } + + convs := make([]*chat.Conversation, 0, len(ids)) + for _, id := range ids { + c, err := r.GetByID(ctx, id) + if err != nil { + return nil, 0, err + } + convs = append(convs, c) + } + return convs, total, nil +} + +// UpdateGroup persists name and avatar changes. +func (r *ChatConversationRepository) UpdateGroup(ctx context.Context, conv *chat.Conversation) error { + const q = ` + UPDATE chat_conversation SET name=$1, avatar_url=$2, updated_at=$3 + WHERE conversation_id=$4 AND deleted_at IS NULL` + if _, err := r.db.ExecContext(ctx, q, + conv.Name(), conv.AvatarURL(), time.Now().UTC(), conv.ID(), + ); err != nil { + return fmt.Errorf("chat conv repo: update group: %w", err) + } + return nil +} + +// AddParticipants inserts new participant rows. +func (r *ChatConversationRepository) AddParticipants(ctx context.Context, convID uuid.UUID, participants []*chat.Participant) error { + const q = ` + INSERT INTO chat_participant (conversation_id, user_id, role, joined_at) + VALUES ($1,$2,$3,$4) + ON CONFLICT (conversation_id, user_id) DO UPDATE SET left_at = NULL, role = $3` + for _, p := range participants { + if _, err := r.db.ExecContext(ctx, q, + convID, p.UserID(), p.Role().String(), p.JoinedAt(), + ); err != nil { + return fmt.Errorf("chat conv repo: add participant: %w", err) + } + } + return nil +} + +// RemoveParticipant sets left_at for a participant. +func (r *ChatConversationRepository) RemoveParticipant(ctx context.Context, convID, userID uuid.UUID) error { + const q = ` + UPDATE chat_participant SET left_at = NOW() + WHERE conversation_id=$1 AND user_id=$2 AND left_at IS NULL` + if _, err := r.db.ExecContext(ctx, q, convID, userID); err != nil { + return fmt.Errorf("chat conv repo: remove participant: %w", err) + } + return nil +} + +// UpdateLastReadAt updates chat_participant.last_read_at for a user. +func (r *ChatConversationRepository) UpdateLastReadAt(ctx context.Context, convID, userID uuid.UUID, at time.Time) error { + const q = `UPDATE chat_participant SET last_read_at=$1 WHERE conversation_id=$2 AND user_id=$3` + if _, err := r.db.ExecContext(ctx, q, at, convID, userID); err != nil { + return fmt.Errorf("chat conv repo: update last_read_at: %w", err) + } + return nil +} + +func (r *ChatConversationRepository) loadParticipants(ctx context.Context, convID uuid.UUID) ([]*chat.Participant, error) { + const q = ` + SELECT user_id, role, joined_at, left_at, last_read_at + FROM chat_participant WHERE conversation_id = $1` + + rows, err := r.db.QueryContext(ctx, q, convID) + if err != nil { + return nil, fmt.Errorf("chat conv repo: load participants: %w", err) + } + defer rows.Close() + + var parts []*chat.Participant + for rows.Next() { + var ( + userID uuid.UUID + roleStr string + joinedAt time.Time + leftAt, lastReadAt *time.Time + ) + if err := rows.Scan(&userID, &roleStr, &joinedAt, &leftAt, &lastReadAt); err != nil { + return nil, fmt.Errorf("chat conv repo: scan participant: %w", err) + } + role, err := chat.ParseRole(roleStr) + if err != nil { + return nil, fmt.Errorf("chat conv repo: parse role: %w", err) + } + parts = append(parts, chat.ReconstructParticipant(convID, userID, role, joinedAt, leftAt, lastReadAt)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat conv repo: participants rows err: %w", err) + } + return parts, nil +} + +func (r *ChatConversationRepository) scanConversation(row *sql.Row) (*chat.Conversation, error) { + var ( + id uuid.UUID + typeStr string + name sql.NullString + avatarURL sql.NullString + encryptionKey []byte + createdBy string + createdAt time.Time + updatedAt time.Time + deletedAt *time.Time + ) + if err := row.Scan(&id, &typeStr, &name, &avatarURL, &encryptionKey, + &createdBy, &createdAt, &updatedAt, &deletedAt); err != nil { + return nil, err + } + convType, err := chat.ParseType(typeStr) + if err != nil { + return nil, fmt.Errorf("scan conversation: parse type: %w", err) + } + return chat.Reconstruct(id, convType, name.String, avatarURL.String, encryptionKey, createdBy, createdAt, updatedAt, deletedAt, nil), nil +} diff --git a/services/iam/internal/infrastructure/postgres/chat_message_repository.go b/services/iam/internal/infrastructure/postgres/chat_message_repository.go new file mode 100644 index 00000000..d16490af --- /dev/null +++ b/services/iam/internal/infrastructure/postgres/chat_message_repository.go @@ -0,0 +1,350 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// ChatMessageRepository implements chat.MessageRepository. +type ChatMessageRepository struct { + db *DB +} + +// NewChatMessageRepository constructs the repo. +func NewChatMessageRepository(db *DB) *ChatMessageRepository { + return &ChatMessageRepository{db: db} +} + +// Create inserts a new message. +func (r *ChatMessageRepository) Create(ctx context.Context, msg *chat.Message) error { + var replyToID *uuid.UUID + if msg.ReplyToID() != uuid.Nil { + id := msg.ReplyToID() + replyToID = &id + } + const q = ` + INSERT INTO chat_message + (message_id, conversation_id, sender_user_id, + body_encrypted, body_plain_encrypted, + is_edited, is_deleted, reply_to_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)` + if _, err := r.db.ExecContext(ctx, q, + msg.MessageID(), msg.ConversationID(), msg.SenderUserID(), + msg.BodyEncrypted(), msg.BodyPlainEncrypted(), + msg.IsEdited(), msg.IsDeleted(), replyToID, + msg.CreatedAt(), msg.UpdatedAt(), + ); err != nil { + return fmt.Errorf("chat msg repo: create: %w", err) + } + return nil +} + +// GetByID returns a message with its read receipts. +func (r *ChatMessageRepository) GetByID(ctx context.Context, id uuid.UUID) (*chat.Message, error) { + const q = ` + SELECT message_id, conversation_id, sender_user_id, + body_encrypted, body_plain_encrypted, + is_edited, is_deleted, reply_to_id, created_at, updated_at + FROM chat_message WHERE message_id = $1` + + msg, err := r.scanMessage(r.db.QueryRowContext(ctx, q, id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, chat.ErrMessageNotFound + } + return nil, fmt.Errorf("chat msg repo: get by id: %w", err) + } + receipts, err := r.loadReceipts(ctx, id) + if err != nil { + return nil, err + } + return chat.ReconstructMessage( + msg.MessageID(), msg.ConversationID(), msg.SenderUserID(), + msg.BodyEncrypted(), msg.BodyPlainEncrypted(), + msg.IsEdited(), msg.IsDeleted(), msg.ReplyToID(), + receipts, msg.CreatedAt(), msg.UpdatedAt(), + ), nil +} + +// ListByConversation returns messages using cursor-based pagination (newest first). +func (r *ChatMessageRepository) ListByConversation(ctx context.Context, convID uuid.UUID, pageSize int, beforeCursor string) ([]*chat.Message, string, bool, error) { + fetchSize := pageSize + 1 + + var rows *sql.Rows + var err error + + if beforeCursor == "" { + const q = ` + SELECT message_id, conversation_id, sender_user_id, + body_encrypted, body_plain_encrypted, + is_edited, is_deleted, reply_to_id, created_at, updated_at + FROM chat_message + WHERE conversation_id = $1 AND is_deleted = FALSE + ORDER BY created_at DESC, message_id DESC + LIMIT $2` + rows, err = r.db.QueryContext(ctx, q, convID, fetchSize) + } else { + cursorTime, cursorID, parseErr := decodeChatCursor(beforeCursor) + if parseErr != nil { + return nil, "", false, fmt.Errorf("chat msg repo: invalid cursor: %w", parseErr) + } + const q = ` + SELECT message_id, conversation_id, sender_user_id, + body_encrypted, body_plain_encrypted, + is_edited, is_deleted, reply_to_id, created_at, updated_at + FROM chat_message + WHERE conversation_id = $1 AND is_deleted = FALSE + AND (created_at, message_id) < ($2, $3) + ORDER BY created_at DESC, message_id DESC + LIMIT $4` + rows, err = r.db.QueryContext(ctx, q, convID, cursorTime, cursorID, fetchSize) + } + if err != nil { + return nil, "", false, fmt.Errorf("chat msg repo: list: %w", err) + } + defer rows.Close() + + var msgs []*chat.Message + for rows.Next() { + msg, scanErr := r.scanMessageRows(rows) + if scanErr != nil { + return nil, "", false, fmt.Errorf("chat msg repo: scan: %w", scanErr) + } + msgs = append(msgs, msg) + } + if err := rows.Err(); err != nil { + return nil, "", false, fmt.Errorf("chat msg repo: rows err: %w", err) + } + + hasMore := len(msgs) > pageSize + if hasMore { + msgs = msgs[:pageSize] + } + + nextCursor := "" + if hasMore && len(msgs) > 0 { + last := msgs[len(msgs)-1] + nextCursor = encodeChatCursor(last.CreatedAt(), last.MessageID()) + } + return msgs, nextCursor, hasMore, nil +} + +// UpdateBody persists body changes after an edit. +func (r *ChatMessageRepository) UpdateBody(ctx context.Context, msg *chat.Message) error { + const q = ` + UPDATE chat_message + SET body_encrypted=$1, body_plain_encrypted=$2, is_edited=TRUE, updated_at=NOW() + WHERE message_id=$3` + if _, err := r.db.ExecContext(ctx, q, + msg.BodyEncrypted(), msg.BodyPlainEncrypted(), msg.MessageID(), + ); err != nil { + return fmt.Errorf("chat msg repo: update body: %w", err) + } + return nil +} + +// MarkDeleted sets is_deleted to true. +func (r *ChatMessageRepository) MarkDeleted(ctx context.Context, id uuid.UUID) error { + const q = `UPDATE chat_message SET is_deleted=TRUE, updated_at=NOW() WHERE message_id=$1` + if _, err := r.db.ExecContext(ctx, q, id); err != nil { + return fmt.Errorf("chat msg repo: mark deleted: %w", err) + } + return nil +} + +// SaveEditHistory inserts a pre-edit snapshot. +func (r *ChatMessageRepository) SaveEditHistory(ctx context.Context, entry *chat.EditHistoryEntry) error { + const q = ` + INSERT INTO chat_message_edit_history (message_id, body_encrypted, edited_by, edited_at) + VALUES ($1,$2,$3,$4)` + if _, err := r.db.ExecContext(ctx, q, + entry.MessageID(), entry.BodyEncrypted(), entry.EditedBy(), entry.EditedAt(), + ); err != nil { + return fmt.Errorf("chat msg repo: save edit history: %w", err) + } + return nil +} + +// GetEditHistory returns all edit history for a message, newest first. +func (r *ChatMessageRepository) GetEditHistory(ctx context.Context, messageID uuid.UUID) ([]*chat.EditHistoryEntry, error) { + const q = ` + SELECT history_id, message_id, body_encrypted, edited_by, edited_at + FROM chat_message_edit_history + WHERE message_id=$1 ORDER BY edited_at DESC` + + rows, err := r.db.QueryContext(ctx, q, messageID) + if err != nil { + return nil, fmt.Errorf("chat msg repo: get edit history: %w", err) + } + defer rows.Close() + + var entries []*chat.EditHistoryEntry + for rows.Next() { + var ( + histID int64 + msgID, editedBy uuid.UUID + bodyEnc []byte + editedAt time.Time + ) + if err := rows.Scan(&histID, &msgID, &bodyEnc, &editedBy, &editedAt); err != nil { + return nil, fmt.Errorf("chat msg repo: scan edit history: %w", err) + } + entries = append(entries, chat.ReconstructEditHistory(histID, msgID, bodyEnc, editedBy, editedAt)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat msg repo: edit history rows err: %w", err) + } + return entries, nil +} + +func (r *ChatMessageRepository) loadReceipts(ctx context.Context, msgID uuid.UUID) ([]*chat.ReadReceipt, error) { + const q = `SELECT message_id, user_id, read_at FROM chat_read_receipt WHERE message_id=$1` + rows, err := r.db.QueryContext(ctx, q, msgID) + if err != nil { + return nil, fmt.Errorf("chat msg repo: load receipts: %w", err) + } + defer rows.Close() + var receipts []*chat.ReadReceipt + for rows.Next() { + var ( + mID, uID uuid.UUID + readAt time.Time + ) + if err := rows.Scan(&mID, &uID, &readAt); err != nil { + return nil, fmt.Errorf("chat msg repo: scan receipt: %w", err) + } + receipts = append(receipts, chat.NewReadReceipt(mID, uID, readAt)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat msg repo: receipt rows err: %w", err) + } + return receipts, nil +} + +func (r *ChatMessageRepository) scanMessage(row *sql.Row) (*chat.Message, error) { + var ( + msgID, convID, senderID uuid.UUID + bodyEnc, bodyPlainEnc []byte + isEdited, isDeleted bool + replyToID *uuid.UUID + createdAt, updatedAt time.Time + ) + if err := row.Scan(&msgID, &convID, &senderID, &bodyEnc, &bodyPlainEnc, + &isEdited, &isDeleted, &replyToID, &createdAt, &updatedAt); err != nil { + return nil, err + } + replyTo := uuid.Nil + if replyToID != nil { + replyTo = *replyToID + } + return chat.ReconstructMessage(msgID, convID, senderID, bodyEnc, bodyPlainEnc, + isEdited, isDeleted, replyTo, nil, createdAt, updatedAt), nil +} + +func (r *ChatMessageRepository) scanMessageRows(rows *sql.Rows) (*chat.Message, error) { + var ( + msgID, convID, senderID uuid.UUID + bodyEnc, bodyPlainEnc []byte + isEdited, isDeleted bool + replyToID *uuid.UUID + createdAt, updatedAt time.Time + ) + if err := rows.Scan(&msgID, &convID, &senderID, &bodyEnc, &bodyPlainEnc, + &isEdited, &isDeleted, &replyToID, &createdAt, &updatedAt); err != nil { + return nil, err + } + replyTo := uuid.Nil + if replyToID != nil { + replyTo = *replyToID + } + return chat.ReconstructMessage(msgID, convID, senderID, bodyEnc, bodyPlainEnc, + isEdited, isDeleted, replyTo, nil, createdAt, updatedAt), nil +} + +// ChatReadReceiptRepository implements chat.ReadReceiptRepository. +type ChatReadReceiptRepository struct { + db *DB +} + +// NewChatReadReceiptRepository constructs the repo. +func NewChatReadReceiptRepository(db *DB) *ChatReadReceiptRepository { + return &ChatReadReceiptRepository{db: db} +} + +// Upsert inserts or ignores a read receipt (idempotent). +func (r *ChatReadReceiptRepository) Upsert(ctx context.Context, messageID, userID uuid.UUID) error { + const q = `INSERT INTO chat_read_receipt (message_id, user_id, read_at) VALUES ($1,$2,NOW()) ON CONFLICT DO NOTHING` + if _, err := r.db.ExecContext(ctx, q, messageID, userID); err != nil { + return fmt.Errorf("chat receipt repo: upsert: %w", err) + } + return nil +} + +// ListByMessage returns all receipts for a message. +func (r *ChatReadReceiptRepository) ListByMessage(ctx context.Context, msgID uuid.UUID) ([]*chat.ReadReceipt, error) { + const q = `SELECT message_id, user_id, read_at FROM chat_read_receipt WHERE message_id=$1` + rows, err := r.db.QueryContext(ctx, q, msgID) + if err != nil { + return nil, fmt.Errorf("chat receipt repo: list: %w", err) + } + defer rows.Close() + var receipts []*chat.ReadReceipt + for rows.Next() { + var ( + mID, uID uuid.UUID + readAt time.Time + ) + if err := rows.Scan(&mID, &uID, &readAt); err != nil { + return nil, fmt.Errorf("chat receipt repo: scan: %w", err) + } + receipts = append(receipts, chat.NewReadReceipt(mID, uID, readAt)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat receipt repo: rows err: %w", err) + } + return receipts, nil +} + +// UpsertBulk marks multiple messages as read for a user. +func (r *ChatReadReceiptRepository) UpsertBulk(ctx context.Context, messageIDs []uuid.UUID, userID uuid.UUID) error { + for _, msgID := range messageIDs { + if err := r.Upsert(ctx, msgID, userID); err != nil { + return err + } + } + return nil +} + +func encodeChatCursor(createdAt time.Time, messageID uuid.UUID) string { + raw := createdAt.UTC().Format(time.RFC3339Nano) + "|" + messageID.String() + return base64.StdEncoding.EncodeToString([]byte(raw)) +} + +func decodeChatCursor(cursor string) (time.Time, uuid.UUID, error) { + b, err := base64.StdEncoding.DecodeString(cursor) + if err != nil { + return time.Time{}, uuid.Nil, fmt.Errorf("decode base64: %w", err) + } + parts := strings.SplitN(string(b), "|", 2) + if len(parts) != 2 { + return time.Time{}, uuid.Nil, fmt.Errorf("invalid cursor format") + } + t, err := time.Parse(time.RFC3339Nano, parts[0]) + if err != nil { + return time.Time{}, uuid.Nil, fmt.Errorf("parse time: %w", err) + } + id, err := uuid.Parse(parts[1]) + if err != nil { + return time.Time{}, uuid.Nil, fmt.Errorf("parse uuid: %w", err) + } + return t, id, nil +} diff --git a/services/iam/internal/infrastructure/postgres/chatbot_audit_repository.go b/services/iam/internal/infrastructure/postgres/chatbot_audit_repository.go new file mode 100644 index 00000000..ee476226 --- /dev/null +++ b/services/iam/internal/infrastructure/postgres/chatbot_audit_repository.go @@ -0,0 +1,46 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// ChatbotAuditEntry represents one chatbot interaction. +type ChatbotAuditEntry struct { + UserID uuid.UUID + SessionID string + RequestTokens int + ResponseTokens int + ToolsCalled []string + WasBlocked bool + BlockReason string +} + +// ChatbotAuditRepository persists chatbot audit logs. +type ChatbotAuditRepository struct { + db *DB +} + +// NewChatbotAuditRepository constructs the repo. +func NewChatbotAuditRepository(db *DB) *ChatbotAuditRepository { + return &ChatbotAuditRepository{db: db} +} + +// Create inserts a new audit log entry. +func (r *ChatbotAuditRepository) Create(ctx context.Context, entry *ChatbotAuditEntry) error { + const q = ` + INSERT INTO chatbot_audit_log + (user_id, session_id, request_tokens, response_tokens, tools_called, was_blocked, block_reason) + VALUES ($1,$2,$3,$4,$5,$6,$7)` + toolsStr := "{" + strings.Join(entry.ToolsCalled, ",") + "}" + if _, err := r.db.ExecContext(ctx, q, + entry.UserID, entry.SessionID, entry.RequestTokens, entry.ResponseTokens, + toolsStr, entry.WasBlocked, entry.BlockReason, + ); err != nil { + return fmt.Errorf("chatbot audit repo: create: %w", err) + } + return nil +} diff --git a/services/iam/migrations/postgres/000070_create_chat_conversation.down.sql b/services/iam/migrations/postgres/000070_create_chat_conversation.down.sql new file mode 100644 index 00000000..fe84048b --- /dev/null +++ b/services/iam/migrations/postgres/000070_create_chat_conversation.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_conversation; diff --git a/services/iam/migrations/postgres/000070_create_chat_conversation.up.sql b/services/iam/migrations/postgres/000070_create_chat_conversation.up.sql new file mode 100644 index 00000000..eceb5302 --- /dev/null +++ b/services/iam/migrations/postgres/000070_create_chat_conversation.up.sql @@ -0,0 +1,17 @@ +-- 000070: Create chat_conversation table for direct and group conversations. +CREATE TABLE IF NOT EXISTS chat_conversation ( + conversation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(10) NOT NULL CHECK (type IN ('DIRECT', 'GROUP')), + name VARCHAR(200), + avatar_url VARCHAR(500), + encryption_key BYTEA NOT NULL, + created_by VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + deleted_by VARCHAR(100) +); + +CREATE INDEX IF NOT EXISTS idx_chat_conversation_created + ON chat_conversation(created_at DESC) + WHERE deleted_at IS NULL; diff --git a/services/iam/migrations/postgres/000071_create_chat_participant.down.sql b/services/iam/migrations/postgres/000071_create_chat_participant.down.sql new file mode 100644 index 00000000..1fa55304 --- /dev/null +++ b/services/iam/migrations/postgres/000071_create_chat_participant.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_participant; diff --git a/services/iam/migrations/postgres/000071_create_chat_participant.up.sql b/services/iam/migrations/postgres/000071_create_chat_participant.up.sql new file mode 100644 index 00000000..6252c874 --- /dev/null +++ b/services/iam/migrations/postgres/000071_create_chat_participant.up.sql @@ -0,0 +1,15 @@ +-- 000071: Create chat_participant table linking users to conversations. +CREATE TABLE IF NOT EXISTS chat_participant ( + conversation_id UUID NOT NULL REFERENCES chat_conversation(conversation_id), + user_id UUID NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'MEMBER' + CHECK (role IN ('OWNER', 'ADMIN', 'MEMBER')), + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + left_at TIMESTAMPTZ, + last_read_at TIMESTAMPTZ, + PRIMARY KEY (conversation_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_chat_participant_user + ON chat_participant(user_id) + WHERE left_at IS NULL; diff --git a/services/iam/migrations/postgres/000072_create_chat_message.down.sql b/services/iam/migrations/postgres/000072_create_chat_message.down.sql new file mode 100644 index 00000000..6643ebb8 --- /dev/null +++ b/services/iam/migrations/postgres/000072_create_chat_message.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_message; diff --git a/services/iam/migrations/postgres/000072_create_chat_message.up.sql b/services/iam/migrations/postgres/000072_create_chat_message.up.sql new file mode 100644 index 00000000..3f17ed94 --- /dev/null +++ b/services/iam/migrations/postgres/000072_create_chat_message.up.sql @@ -0,0 +1,20 @@ +-- 000072: Create chat_message table with encrypted body storage. +CREATE TABLE IF NOT EXISTS chat_message ( + message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES chat_conversation(conversation_id), + sender_user_id UUID NOT NULL, + body_encrypted BYTEA NOT NULL, + body_plain_encrypted BYTEA NOT NULL, + is_edited BOOLEAN NOT NULL DEFAULT FALSE, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + reply_to_id UUID REFERENCES chat_message(message_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_chat_message_conv_created + ON chat_message(conversation_id, created_at DESC) + WHERE is_deleted = FALSE; + +CREATE INDEX IF NOT EXISTS idx_chat_message_sender + ON chat_message(sender_user_id); diff --git a/services/iam/migrations/postgres/000073_create_chat_message_edit_history.down.sql b/services/iam/migrations/postgres/000073_create_chat_message_edit_history.down.sql new file mode 100644 index 00000000..9a22c106 --- /dev/null +++ b/services/iam/migrations/postgres/000073_create_chat_message_edit_history.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_message_edit_history; diff --git a/services/iam/migrations/postgres/000073_create_chat_message_edit_history.up.sql b/services/iam/migrations/postgres/000073_create_chat_message_edit_history.up.sql new file mode 100644 index 00000000..74e2199d --- /dev/null +++ b/services/iam/migrations/postgres/000073_create_chat_message_edit_history.up.sql @@ -0,0 +1,11 @@ +-- 000073: Create chat_message_edit_history table for message edit tracking. +CREATE TABLE IF NOT EXISTS chat_message_edit_history ( + history_id BIGSERIAL PRIMARY KEY, + message_id UUID NOT NULL REFERENCES chat_message(message_id), + body_encrypted BYTEA NOT NULL, + edited_by UUID NOT NULL, + edited_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_chat_edit_history_msg + ON chat_message_edit_history(message_id, edited_at DESC); diff --git a/services/iam/migrations/postgres/000074_create_chat_read_receipt.down.sql b/services/iam/migrations/postgres/000074_create_chat_read_receipt.down.sql new file mode 100644 index 00000000..c9698a56 --- /dev/null +++ b/services/iam/migrations/postgres/000074_create_chat_read_receipt.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_read_receipt; diff --git a/services/iam/migrations/postgres/000074_create_chat_read_receipt.up.sql b/services/iam/migrations/postgres/000074_create_chat_read_receipt.up.sql new file mode 100644 index 00000000..2a9c262d --- /dev/null +++ b/services/iam/migrations/postgres/000074_create_chat_read_receipt.up.sql @@ -0,0 +1,10 @@ +-- 000074: Create chat_read_receipt table for per-message read tracking. +CREATE TABLE IF NOT EXISTS chat_read_receipt ( + message_id UUID NOT NULL REFERENCES chat_message(message_id), + user_id UUID NOT NULL, + read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (message_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_chat_read_receipt_msg + ON chat_read_receipt(message_id); diff --git a/services/iam/migrations/postgres/000075_create_chatbot_audit_log.down.sql b/services/iam/migrations/postgres/000075_create_chatbot_audit_log.down.sql new file mode 100644 index 00000000..ca620aa6 --- /dev/null +++ b/services/iam/migrations/postgres/000075_create_chatbot_audit_log.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chatbot_audit_log; diff --git a/services/iam/migrations/postgres/000075_create_chatbot_audit_log.up.sql b/services/iam/migrations/postgres/000075_create_chatbot_audit_log.up.sql new file mode 100644 index 00000000..2c4449d0 --- /dev/null +++ b/services/iam/migrations/postgres/000075_create_chatbot_audit_log.up.sql @@ -0,0 +1,15 @@ +-- 000075: Create chatbot_audit_log table for AI chatbot usage tracking. +CREATE TABLE IF NOT EXISTS chatbot_audit_log ( + log_id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL, + session_id VARCHAR(100) NOT NULL, + request_tokens INT NOT NULL DEFAULT 0, + response_tokens INT NOT NULL DEFAULT 0, + tools_called TEXT[], + was_blocked BOOLEAN NOT NULL DEFAULT FALSE, + block_reason VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_chatbot_audit_user_date + ON chatbot_audit_log(user_id, created_at DESC); diff --git a/services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql b/services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql new file mode 100644 index 00000000..0eef2042 --- /dev/null +++ b/services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql @@ -0,0 +1,32 @@ +-- 000076 down: Remove chat menu entries and permissions. + +DELETE FROM role_permissions +WHERE permission_id IN ( + SELECT permission_id FROM mst_permission + WHERE permission_code IN ( + 'iam.chat.message.view', + 'iam.chat.message.create', + 'iam.chat.message.delete', + 'iam.chatbot.assistant.use' + ) +); + +DELETE FROM menu_permissions +WHERE menu_id IN ( + '00000000-0000-0000-0002-000000000017', + '00000000-0000-0000-0003-000000000041' +); + +DELETE FROM mst_permission +WHERE permission_code IN ( + 'iam.chat.message.view', + 'iam.chat.message.create', + 'iam.chat.message.delete', + 'iam.chatbot.assistant.use' +); + +DELETE FROM mst_menu +WHERE menu_id IN ( + '00000000-0000-0000-0002-000000000017', + '00000000-0000-0000-0003-000000000041' +); diff --git a/services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql b/services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql new file mode 100644 index 00000000..3a9b0567 --- /dev/null +++ b/services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql @@ -0,0 +1,71 @@ +-- 000076: Seed chat menu entries and permissions. + +-- Level-2: Chat parent menu +INSERT INTO mst_menu (menu_id, parent_id, menu_code, menu_title, menu_url, icon_name, service_name, menu_level, sort_order, is_visible, is_active, created_by) +VALUES ( + '00000000-0000-0000-0002-000000000017', + NULL, + 'CHAT', + 'Chat', + '/chat', + 'MessageSquare', + 'iam', + 2, + 17, + TRUE, + TRUE, + 'system' +) ON CONFLICT (menu_code) DO NOTHING; + +-- Level-3: /chat page leaf +INSERT INTO mst_menu (menu_id, parent_id, menu_code, menu_title, menu_url, icon_name, service_name, menu_level, sort_order, is_visible, is_active, created_by) +VALUES ( + '00000000-0000-0000-0003-000000000041', + '00000000-0000-0000-0002-000000000017', + 'CHAT_PAGE', + 'All Conversations', + '/chat', + 'MessageSquare', + 'iam', + 3, + 1, + TRUE, + TRUE, + 'system' +) ON CONFLICT (menu_code) DO NOTHING; + +-- Permissions +INSERT INTO mst_permission (permission_id, permission_code, name, description, created_by) +VALUES + (gen_random_uuid(), 'iam.chat.message.view', 'Chat View', 'View chat conversations and messages.', 'system'), + (gen_random_uuid(), 'iam.chat.message.create', 'Chat Create Message', 'Send messages in chat.', 'system'), + (gen_random_uuid(), 'iam.chat.message.delete', 'Chat Delete Message', 'Delete own messages in chat.', 'system'), + (gen_random_uuid(), 'iam.chatbot.assistant.use', 'Chatbot Use', 'Use the AI chatbot assistant.', 'system') +ON CONFLICT (permission_code) DO NOTHING; + +-- Wire menu → permission (CHAT_PAGE visible to anyone with chat.message.view) +INSERT INTO menu_permissions (id, menu_id, permission_id, assigned_by) +SELECT + gen_random_uuid(), + '00000000-0000-0000-0003-000000000041', + permission_id, + 'system' +FROM mst_permission +WHERE permission_code = 'iam.chat.message.view' +ON CONFLICT (menu_id, permission_id) DO NOTHING; + +-- Assign all chat permissions to SUPER_ADMIN role +INSERT INTO role_permissions (role_id, permission_id) +SELECT + r.role_id, + p.permission_id +FROM mst_role r +CROSS JOIN mst_permission p +WHERE r.role_code = 'SUPER_ADMIN' + AND p.permission_code IN ( + 'iam.chat.message.view', + 'iam.chat.message.create', + 'iam.chat.message.delete', + 'iam.chatbot.assistant.use' + ) +ON CONFLICT DO NOTHING; From 23751c6a2d9a992adc41a2206dd0b9b4677b51fd Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 05:24:56 +0700 Subject: [PATCH 03/17] feat(iam): add offline email notifications for chat messages When a chat message is sent, check if each recipient is online via Redis presence. If offline, create a TypeChat notification which triggers the existing email dispatch pipeline. Debounced per conversation/user pair (5-minute TTL) to prevent email spam. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/iam/cmd/server/main.go | 6 +- .../application/chat/send_message_handler.go | 77 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index 1ea74b30..d42fc9c9 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -235,13 +235,17 @@ func run() error { chatConvRepo := postgres.NewChatConversationRepository(db) chatMsgRepo := postgres.NewChatMessageRepository(db) chatReceiptRepo := postgres.NewChatReadReceiptRepository(db) + sendMsgHandler := appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster) + if presenceSvc != nil && redisClient != nil { + sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, redisClient.Client) + } chatHandler = grpcdelivery.NewChatHandler( appChat.NewCreateDirectHandler(chatConvRepo, chatEnc), appChat.NewCreateGroupHandler(chatConvRepo, chatEnc), appChat.NewGetConversationHandler(chatConvRepo), appChat.NewListConversationsHandler(chatConvRepo), appChat.NewLeaveConversationHandler(chatConvRepo), - appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster), + sendMsgHandler, appChat.NewEditMessageHandler(chatConvRepo, chatMsgRepo, chatEnc, chatBroadcaster), appChat.NewDeleteMessageHandler(chatConvRepo, chatMsgRepo, chatBroadcaster), appChat.NewListMessagesHandler(chatConvRepo, chatMsgRepo, chatEnc), diff --git a/services/iam/internal/application/chat/send_message_handler.go b/services/iam/internal/application/chat/send_message_handler.go index bb1fb17b..587ea005 100644 --- a/services/iam/internal/application/chat/send_message_handler.go +++ b/services/iam/internal/application/chat/send_message_handler.go @@ -3,11 +3,15 @@ package chat import ( "context" "fmt" + "time" "github.com/google/uuid" + "github.com/redis/go-redis/v9" "github.com/rs/zerolog/log" + appnotif "github.com/mutugading/goapps-backend/services/iam/internal/application/notification" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/domain/notification" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" ) @@ -19,6 +23,9 @@ type SendMessageHandler struct { receiptRepo chat.ReadReceiptRepository enc *crypto.Encryptor broadcaster *chatinfra.Broadcaster + presence *chatinfra.PresenceService + notifCreate *appnotif.CreateHandler + rdb *redis.Client } // NewSendMessageHandler constructs the handler. @@ -32,6 +39,14 @@ func NewSendMessageHandler( return &SendMessageHandler{convRepo: convRepo, msgRepo: msgRepo, receiptRepo: receiptRepo, enc: enc, broadcaster: broadcaster} } +// WithOfflineNotification enables email notifications for offline users. +func (h *SendMessageHandler) WithOfflineNotification(presence *chatinfra.PresenceService, notifCreate *appnotif.CreateHandler, rdb *redis.Client) *SendMessageHandler { + h.presence = presence + h.notifCreate = notifCreate + h.rdb = rdb + return h +} + // Handle validates participation, encrypts body, saves, and broadcasts. func (h *SendMessageHandler) Handle(ctx context.Context, senderID, convID uuid.UUID, body string, replyToID uuid.UUID) (*chat.Message, error) { conv, err := h.convRepo.GetByID(ctx, convID) @@ -68,5 +83,67 @@ func (h *SendMessageHandler) Handle(ctx context.Context, senderID, convID uuid.U } broadcastMessageEvent(h.broadcaster, conv, msg, body, "message_received") + + h.notifyOfflineParticipants(ctx, conv, msg, senderID, body) return msg, nil } + +const emailDebounceTTL = 5 * time.Minute + +func (h *SendMessageHandler) notifyOfflineParticipants(ctx context.Context, conv *chat.Conversation, msg *chat.Message, senderID uuid.UUID, body string) { + if h.presence == nil || h.notifCreate == nil { + return + } + truncatedBody := body + if len(truncatedBody) > 100 { + truncatedBody = truncatedBody[:100] + "..." + } + for _, p := range conv.Participants() { + if !p.IsActive() || p.UserID() == senderID { + continue + } + h.notifyIfOffline(ctx, p.UserID(), msg, senderID, truncatedBody) + } +} + +func (h *SendMessageHandler) notifyIfOffline(ctx context.Context, recipientID uuid.UUID, msg *chat.Message, senderID uuid.UUID, truncatedBody string) { + online, err := h.presence.IsOnline(ctx, recipientID) + if err != nil { + log.Warn().Err(err).Str("user", recipientID.String()).Msg("send message: check online status") + return + } + if online { + return + } + if h.isDebounced(ctx, msg.ConversationID(), recipientID) { + return + } + _, err = h.notifCreate.Handle(ctx, appnotif.CreateCommand{ + RecipientUserID: recipientID, + Type: notification.TypeChat, + Severity: notification.SeverityInfo, + Title: "New chat message", + Body: truncatedBody, + ActionType: notification.ActionNavigate, + ActionPayload: `{"url":"/chat"}`, + SourceType: "chat_message", + SourceID: msg.MessageID().String(), + CreatedBy: senderID.String(), + }) + if err != nil { + log.Warn().Err(err).Msg("send message: create offline notification") + } +} + +func (h *SendMessageHandler) isDebounced(ctx context.Context, convID, userID uuid.UUID) bool { + if h.rdb == nil { + return false + } + key := fmt.Sprintf("chat:email-debounce:%s:%s", convID, userID) + set, err := h.rdb.SetNX(ctx, key, "1", emailDebounceTTL).Result() + if err != nil { + log.Warn().Err(err).Msg("send message: debounce check") + return false + } + return !set +} From b8cdaaec36662e17871213eb0469f957eeb5f06f Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 05:32:03 +0700 Subject: [PATCH 04/17] fix(iam): fix migration 000076 menu hierarchy and permission schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat menu: level-2 with NULL parent → level-1 root (constraint requires non-root menus to have a parent) - mst_permission: use actual columns (permission_name, service_name, module_name, action_type) instead of plan's assumed 'name' column - role_permissions: include required id, assigned_by columns - Permission code: iam.chatbot.assistant.use → iam.chatbot.assistant.view (action_type CHECK constraint doesn't include 'use') Co-Authored-By: Claude Opus 4.6 (1M context) --- .../000076_chat_menu_permissions.down.sql | 12 +++--- .../000076_chat_menu_permissions.up.sql | 38 ++++++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql b/services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql index 0eef2042..7036b9e7 100644 --- a/services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql +++ b/services/iam/migrations/postgres/000076_chat_menu_permissions.down.sql @@ -7,14 +7,14 @@ WHERE permission_id IN ( 'iam.chat.message.view', 'iam.chat.message.create', 'iam.chat.message.delete', - 'iam.chatbot.assistant.use' + 'iam.chatbot.assistant.view' ) ); DELETE FROM menu_permissions WHERE menu_id IN ( - '00000000-0000-0000-0002-000000000017', - '00000000-0000-0000-0003-000000000041' + '00000000-0000-0000-0001-000000000008', + '00000000-0000-0000-0002-000000000017' ); DELETE FROM mst_permission @@ -22,11 +22,11 @@ WHERE permission_code IN ( 'iam.chat.message.view', 'iam.chat.message.create', 'iam.chat.message.delete', - 'iam.chatbot.assistant.use' + 'iam.chatbot.assistant.view' ); DELETE FROM mst_menu WHERE menu_id IN ( - '00000000-0000-0000-0002-000000000017', - '00000000-0000-0000-0003-000000000041' + '00000000-0000-0000-0001-000000000008', + '00000000-0000-0000-0002-000000000017' ); diff --git a/services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql b/services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql index 3a9b0567..8eff0d24 100644 --- a/services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql +++ b/services/iam/migrations/postgres/000076_chat_menu_permissions.up.sql @@ -1,33 +1,33 @@ -- 000076: Seed chat menu entries and permissions. --- Level-2: Chat parent menu +-- Level-1: Chat root menu (top-level sidebar section, like Dashboard/Finance) INSERT INTO mst_menu (menu_id, parent_id, menu_code, menu_title, menu_url, icon_name, service_name, menu_level, sort_order, is_visible, is_active, created_by) VALUES ( - '00000000-0000-0000-0002-000000000017', + '00000000-0000-0000-0001-000000000008', NULL, 'CHAT', 'Chat', - '/chat', + NULL, 'MessageSquare', 'iam', - 2, - 17, + 1, + 80, TRUE, TRUE, 'system' ) ON CONFLICT (menu_code) DO NOTHING; --- Level-3: /chat page leaf +-- Level-2: /chat page (child of Chat root) INSERT INTO mst_menu (menu_id, parent_id, menu_code, menu_title, menu_url, icon_name, service_name, menu_level, sort_order, is_visible, is_active, created_by) VALUES ( - '00000000-0000-0000-0003-000000000041', '00000000-0000-0000-0002-000000000017', + '00000000-0000-0000-0001-000000000008', 'CHAT_PAGE', 'All Conversations', '/chat', 'MessageSquare', 'iam', - 3, + 2, 1, TRUE, TRUE, @@ -35,19 +35,19 @@ VALUES ( ) ON CONFLICT (menu_code) DO NOTHING; -- Permissions -INSERT INTO mst_permission (permission_id, permission_code, name, description, created_by) +INSERT INTO mst_permission (permission_id, permission_code, permission_name, description, service_name, module_name, action_type, created_by) VALUES - (gen_random_uuid(), 'iam.chat.message.view', 'Chat View', 'View chat conversations and messages.', 'system'), - (gen_random_uuid(), 'iam.chat.message.create', 'Chat Create Message', 'Send messages in chat.', 'system'), - (gen_random_uuid(), 'iam.chat.message.delete', 'Chat Delete Message', 'Delete own messages in chat.', 'system'), - (gen_random_uuid(), 'iam.chatbot.assistant.use', 'Chatbot Use', 'Use the AI chatbot assistant.', 'system') + (gen_random_uuid(), 'iam.chat.message.view', 'Chat View', 'View chat conversations and messages.', 'iam', 'chat', 'view', 'system'), + (gen_random_uuid(), 'iam.chat.message.create', 'Chat Create Message', 'Send messages in chat.', 'iam', 'chat', 'create', 'system'), + (gen_random_uuid(), 'iam.chat.message.delete', 'Chat Delete Message', 'Delete own messages in chat.', 'iam', 'chat', 'delete', 'system'), + (gen_random_uuid(), 'iam.chatbot.assistant.view', 'Chatbot Access', 'Access the AI chatbot assistant.', 'iam', 'chatbot', 'view', 'system') ON CONFLICT (permission_code) DO NOTHING; -- Wire menu → permission (CHAT_PAGE visible to anyone with chat.message.view) INSERT INTO menu_permissions (id, menu_id, permission_id, assigned_by) SELECT gen_random_uuid(), - '00000000-0000-0000-0003-000000000041', + '00000000-0000-0000-0002-000000000017', permission_id, 'system' FROM mst_permission @@ -55,10 +55,12 @@ WHERE permission_code = 'iam.chat.message.view' ON CONFLICT (menu_id, permission_id) DO NOTHING; -- Assign all chat permissions to SUPER_ADMIN role -INSERT INTO role_permissions (role_id, permission_id) +INSERT INTO role_permissions (id, role_id, permission_id, assigned_by) SELECT + gen_random_uuid(), r.role_id, - p.permission_id + p.permission_id, + 'system' FROM mst_role r CROSS JOIN mst_permission p WHERE r.role_code = 'SUPER_ADMIN' @@ -66,6 +68,6 @@ WHERE r.role_code = 'SUPER_ADMIN' 'iam.chat.message.view', 'iam.chat.message.create', 'iam.chat.message.delete', - 'iam.chatbot.assistant.use' + 'iam.chatbot.assistant.view' ) -ON CONFLICT DO NOTHING; +ON CONFLICT (role_id, permission_id) DO NOTHING; From 9d3ae524908b9573bb7df8f424dcbe7214076e90 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 06:39:52 +0700 Subject: [PATCH 05/17] fix(iam): register ChatService + PresenceService in permission interceptor All 16 ChatService RPCs and 2 PresenceService RPCs were unmapped in methodPermissions, causing the stream/unary permission interceptor to deny them with "unmapped method denied". Added as authenticated-only (empty permission) since participant-level authorization is enforced at the handler level. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../delivery/grpc/permission_interceptor.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/services/iam/internal/delivery/grpc/permission_interceptor.go b/services/iam/internal/delivery/grpc/permission_interceptor.go index 17fa3b3a..bc3dde30 100644 --- a/services/iam/internal/delivery/grpc/permission_interceptor.go +++ b/services/iam/internal/delivery/grpc/permission_interceptor.go @@ -217,6 +217,28 @@ var methodPermissions = map[string]PermissionRequirement{ // RequestNotification is for internal service-to-service use only; // auth is enforced via x-internal-token bypass in the auth interceptor. "/iam.v1.NotificationService/RequestNotification": {Permission: ""}, + + // Chat Service — authenticated only; participant checks are at handler level. + "/iam.v1.ChatService/CreateDirectConversation": {Permission: ""}, + "/iam.v1.ChatService/CreateGroupConversation": {Permission: ""}, + "/iam.v1.ChatService/GetConversation": {Permission: ""}, + "/iam.v1.ChatService/ListConversations": {Permission: ""}, + "/iam.v1.ChatService/UpdateGroupConversation": {Permission: ""}, + "/iam.v1.ChatService/AddParticipants": {Permission: ""}, + "/iam.v1.ChatService/RemoveParticipant": {Permission: ""}, + "/iam.v1.ChatService/LeaveConversation": {Permission: ""}, + "/iam.v1.ChatService/SendMessage": {Permission: ""}, + "/iam.v1.ChatService/EditMessage": {Permission: ""}, + "/iam.v1.ChatService/DeleteMessage": {Permission: ""}, + "/iam.v1.ChatService/ListMessages": {Permission: ""}, + "/iam.v1.ChatService/StreamChatEvents": {Permission: ""}, + "/iam.v1.ChatService/MarkConversationRead": {Permission: ""}, + "/iam.v1.ChatService/SetTyping": {Permission: ""}, + "/iam.v1.ChatService/GetMessageEditHistory": {Permission: ""}, + + // Presence Service — authenticated only. + "/iam.v1.PresenceService/Heartbeat": {Permission: ""}, + "/iam.v1.PresenceService/GetOnlineUsers": {Permission: ""}, } // PermissionInterceptor creates a unary interceptor that checks if the From b3928cca433174e8b9dd136dda8a84b84499251f Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 06:52:44 +0700 Subject: [PATCH 06/17] fix(iam): populate StreamChatEvents proto payload from broadcaster events StreamChatEvents was sending empty proto responses (only EventId, no payload). Now parses the broadcaster JSON payload and maps to the proper oneof variant (MessageReceived, MessageEdited, Typing, etc.) so the frontend SSE bridge receives typed event data. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internal/delivery/grpc/chat_handler.go | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index 30b874f9..f09db4da 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "encoding/json" "errors" "time" @@ -13,6 +14,7 @@ import ( iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" appChat "github.com/mutugading/goapps-backend/services/iam/internal/application/chat" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" ) // ChatHandler implements iamv1.ChatServiceServer. @@ -305,7 +307,7 @@ func (h *ChatHandler) StreamChatEvents(_ *iamv1.StreamChatEventsRequest, stream if !ok { return nil } - resp := &iamv1.StreamChatEventsResponse{EventId: evt.EventID} + resp := buildStreamResponse(evt) if sendErr := stream.Send(resp); sendErr != nil { return sendErr } @@ -363,6 +365,68 @@ func chatSuccessBase() *commonv1.BaseResponse { return &commonv1.BaseResponse{IsSuccess: true, Message: "success", StatusCode: "200"} } +type broadcastPayload struct { + Type string `json:"type"` + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + SenderUserID string `json:"sender_user_id"` + Body string `json:"body"` + IsEdited bool `json:"is_edited"` + IsDeleted bool `json:"is_deleted"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + IsTyping bool `json:"is_typing"` + ReadAt string `json:"read_at"` + IsOnline bool `json:"is_online"` +} + +func buildStreamResponse(evt *chatinfra.Event) *iamv1.StreamChatEventsResponse { + resp := &iamv1.StreamChatEventsResponse{EventId: evt.EventID} + var p broadcastPayload + if err := json.Unmarshal(evt.Payload, &p); err != nil { + return resp + } + msg := &iamv1.MessageProto{ + MessageId: p.MessageID, + ConversationId: p.ConversationID, + SenderUserId: p.SenderUserID, + Body: p.Body, + IsEdited: p.IsEdited, + IsDeleted: p.IsDeleted, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + switch p.Type { + case "message_received": + resp.Payload = &iamv1.StreamChatEventsResponse_MessageReceived{ + MessageReceived: &iamv1.MessageEvent{ConversationId: p.ConversationID, Message: msg}, + } + case "message_edited": + resp.Payload = &iamv1.StreamChatEventsResponse_MessageEdited{ + MessageEdited: &iamv1.MessageEvent{ConversationId: p.ConversationID, Message: msg}, + } + case "message_deleted": + resp.Payload = &iamv1.StreamChatEventsResponse_MessageDeleted{ + MessageDeleted: &iamv1.DeleteEvent{ConversationId: p.ConversationID, MessageId: p.MessageID}, + } + case "typing": + resp.Payload = &iamv1.StreamChatEventsResponse_Typing{ + Typing: &iamv1.TypingEvent{ConversationId: p.ConversationID, UserId: p.UserID, UserName: p.UserName, IsTyping: p.IsTyping}, + } + case "read_receipt": + resp.Payload = &iamv1.StreamChatEventsResponse_ReadReceipt{ + ReadReceipt: &iamv1.ReadEvent{ConversationId: p.ConversationID, UserId: p.UserID, ReadAt: p.ReadAt}, + } + case "presence": + resp.Payload = &iamv1.StreamChatEventsResponse_Presence{ + Presence: &iamv1.PresenceEvent{UserId: p.UserID, IsOnline: p.IsOnline}, + } + } + return resp +} + func mapChatError(err error) error { switch { case errors.Is(err, chat.ErrConversationNotFound), errors.Is(err, chat.ErrMessageNotFound): From 12dd5215bf29ab94a389627cd19c3d9973e0ac9c Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 08:24:17 +0700 Subject: [PATCH 07/17] feat(iam): read CHAT_MASTER_KEY from Viper config (persistent across restarts) - Add chat.master_key to config.yaml with a dev-only default key - Add ChatConfig struct to config loader - Bind CHAT_MASTER_KEY env var for staging/production override - main.go reads from cfg.Chat.MasterKey instead of os.Getenv Local dev: key persists in config.yaml, no need to export env var. K8s: CHAT_MASTER_KEY env var from goapps-chat-secret overrides config. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/iam/cmd/server/main.go | 2 +- services/iam/config.yaml | 3 +++ services/iam/internal/infrastructure/config/config.go | 8 ++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index d42fc9c9..b39b6ac8 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -210,7 +210,7 @@ func run() error { ).WithRequestHandler(notifRequestHandler) // ── Chat + Presence ──────────────────────────────────────────────────── - chatMasterKeyHex := os.Getenv("CHAT_MASTER_KEY") + chatMasterKeyHex := cfg.Chat.MasterKey var chatHandler *grpcdelivery.ChatHandler var presenceGRPCHandler *grpcdelivery.PresenceHandler if chatMasterKeyHex != "" { diff --git a/services/iam/config.yaml b/services/iam/config.yaml index 6981c965..095aaa5d 100644 --- a/services/iam/config.yaml +++ b/services/iam/config.yaml @@ -106,6 +106,9 @@ storage: region: us-east-1 public_url: "" # Override for external access URL, e.g., https://staging-goapps.mutugading.com/storage +chat: + master_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # 64-char hex (32 bytes) — dev only, override via CHAT_MASTER_KEY env in staging/prod + logging: level: debug # debug, info, warn, error format: console # console, json diff --git a/services/iam/internal/infrastructure/config/config.go b/services/iam/internal/infrastructure/config/config.go index 8aab812c..7bcf1eb9 100644 --- a/services/iam/internal/infrastructure/config/config.go +++ b/services/iam/internal/infrastructure/config/config.go @@ -25,6 +25,12 @@ type Config struct { Tracing TracingConfig `mapstructure:"tracing"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` Logger LoggerConfig `mapstructure:"logging"` + Chat ChatConfig `mapstructure:"chat"` +} + +// ChatConfig holds chat encryption configuration. +type ChatConfig struct { + MasterKey string `mapstructure:"master_key"` } // CORSConfig holds CORS configuration for SSO multi-app support. @@ -371,6 +377,8 @@ func bindEnvVars(v *viper.Viper) { {"storage.public_url", "STORAGE_PUBLIC_URL"}, // Internal service-to-service token (shared with trusted backends). {"security.internal_service_token", "INTERNAL_SERVICE_TOKEN"}, + // Chat encryption master key (32-byte hex string). + {"chat.master_key", "CHAT_MASTER_KEY"}, } for _, binding := range envBindings { From 8dc2caeebb8529aa23dd708a63fe027e86c5adc4 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 08:38:51 +0700 Subject: [PATCH 08/17] =?UTF-8?q?refactor(iam):=20typed=20proto=20event=20?= =?UTF-8?q?bus=20=E2=80=94=20eliminate=20JSON=20serialization=20in=20chat?= =?UTF-8?q?=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace JSON-based chat broadcaster with typed proto events: - Event.Payload []byte → Event.Response *iamv1.StreamChatEventsResponse - All broadcast call sites build proto directly (no json.Marshal) - StreamChatEvents handler: zero-copy stream.Send(evt.Response) - Redis cross-pod serialization: proto.Marshal/Unmarshal (binary) - Delete 60 lines of JSON mapping code (broadcastPayload, buildStreamResponse) Before: 5 serialization hops per message (JSON marshal → Redis → unmarshal → proto map → gRPC) After: 2 hops (proto binary to Redis, ts-proto JSON at BFF SSE layer) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/chat/broadcast_helpers.go | 59 ++++++++----- .../chat/delete_message_handler.go | 27 +++--- .../application/chat/mark_read_handler.go | 28 +++---- .../application/chat/set_typing_handler.go | 33 ++++---- .../internal/delivery/grpc/chat_handler.go | 67 +-------------- .../infrastructure/chat/broadcaster.go | 84 ++++++++++--------- .../infrastructure/chat/broadcaster_test.go | 16 ++-- 7 files changed, 135 insertions(+), 179 deletions(-) diff --git a/services/iam/internal/application/chat/broadcast_helpers.go b/services/iam/internal/application/chat/broadcast_helpers.go index ae5aaa44..a9bf8ee4 100644 --- a/services/iam/internal/application/chat/broadcast_helpers.go +++ b/services/iam/internal/application/chat/broadcast_helpers.go @@ -1,12 +1,12 @@ package chat import ( - "encoding/json" "fmt" "time" - "github.com/rs/zerolog/log" + "github.com/google/uuid" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" domainChat "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" ) @@ -14,31 +14,48 @@ import ( // broadcastMessageEvent publishes a message-related event (created, edited) to // every active participant of conv via the given broadcaster. func broadcastMessageEvent(broadcaster *chatinfra.Broadcaster, conv *domainChat.Conversation, msg *domainChat.Message, plainBody, eventType string) { - payload := map[string]any{ - "type": eventType, - "conversation_id": conv.ID().String(), - "message_id": msg.MessageID().String(), - "sender_user_id": msg.SenderUserID().String(), - "body": plainBody, - "is_edited": msg.IsEdited(), - "is_deleted": msg.IsDeleted(), - "created_at": msg.CreatedAt().UTC().Format(time.RFC3339Nano), - "updated_at": msg.UpdatedAt().UTC().Format(time.RFC3339Nano), - } - data, err := json.Marshal(payload) - if err != nil { - log.Warn().Err(err).Msg("broadcast message event: marshal payload") - return - } + msgProto := domainMsgToProto(msg, plainBody) eventID := fmt.Sprintf("msg-%s", msg.MessageID()) + + var resp iamv1.StreamChatEventsResponse + resp.EventId = eventID + switch eventType { + case "message_received": + resp.Payload = &iamv1.StreamChatEventsResponse_MessageReceived{ + MessageReceived: &iamv1.MessageEvent{ConversationId: conv.ID().String(), Message: msgProto}, + } + case "message_edited": + resp.Payload = &iamv1.StreamChatEventsResponse_MessageEdited{ + MessageEdited: &iamv1.MessageEvent{ConversationId: conv.ID().String(), Message: msgProto}, + } + } + for _, part := range conv.Participants() { if !part.IsActive() { continue } broadcaster.Publish(&chatinfra.Event{ - EventID: fmt.Sprintf("%s-%s", eventID, part.UserID()), - UserID: part.UserID(), - Payload: data, + EventID: fmt.Sprintf("%s-%s", eventID, part.UserID()), + UserID: part.UserID(), + Response: &resp, }) } } + +func domainMsgToProto(msg *domainChat.Message, plainBody string) *iamv1.MessageProto { + replyTo := "" + if msg.ReplyToID() != uuid.Nil { + replyTo = msg.ReplyToID().String() + } + return &iamv1.MessageProto{ + MessageId: msg.MessageID().String(), + ConversationId: msg.ConversationID().String(), + SenderUserId: msg.SenderUserID().String(), + Body: plainBody, + IsEdited: msg.IsEdited(), + IsDeleted: msg.IsDeleted(), + ReplyToId: replyTo, + CreatedAt: msg.CreatedAt().UTC().Format(time.RFC3339Nano), + UpdatedAt: msg.UpdatedAt().UTC().Format(time.RFC3339Nano), + } +} diff --git a/services/iam/internal/application/chat/delete_message_handler.go b/services/iam/internal/application/chat/delete_message_handler.go index 3bb5806d..41d2a93d 100644 --- a/services/iam/internal/application/chat/delete_message_handler.go +++ b/services/iam/internal/application/chat/delete_message_handler.go @@ -2,12 +2,11 @@ package chat import ( "context" - "encoding/json" "fmt" "github.com/google/uuid" - "github.com/rs/zerolog/log" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" ) @@ -48,24 +47,24 @@ func (h *DeleteMessageHandler) Handle(ctx context.Context, callerID, convID, msg } func (h *DeleteMessageHandler) broadcastDelete(conv *chat.Conversation, convID, msgID uuid.UUID) { - payload, err := json.Marshal(map[string]string{ - "type": "message_deleted", - "conversation_id": convID.String(), - "message_id": msgID.String(), - }) - if err != nil { - log.Warn().Err(err).Msg("delete message: marshal broadcast") - return - } eventID := fmt.Sprintf("del-%s", msgID) + resp := &iamv1.StreamChatEventsResponse{ + EventId: eventID, + Payload: &iamv1.StreamChatEventsResponse_MessageDeleted{ + MessageDeleted: &iamv1.DeleteEvent{ + ConversationId: convID.String(), + MessageId: msgID.String(), + }, + }, + } for _, p := range conv.Participants() { if !p.IsActive() { continue } h.broadcaster.Publish(&chatinfra.Event{ - EventID: fmt.Sprintf("%s-%s", eventID, p.UserID()), - UserID: p.UserID(), - Payload: payload, + EventID: fmt.Sprintf("%s-%s", eventID, p.UserID()), + UserID: p.UserID(), + Response: resp, }) } } diff --git a/services/iam/internal/application/chat/mark_read_handler.go b/services/iam/internal/application/chat/mark_read_handler.go index d89731ff..c8804c1f 100644 --- a/services/iam/internal/application/chat/mark_read_handler.go +++ b/services/iam/internal/application/chat/mark_read_handler.go @@ -2,13 +2,13 @@ package chat import ( "context" - "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/rs/zerolog/log" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" ) @@ -69,25 +69,25 @@ func (h *MarkReadHandler) Handle(ctx context.Context, callerID, convID uuid.UUID } func (h *MarkReadHandler) broadcastRead(conv *chat.Conversation, readerID uuid.UUID, readAt time.Time) { - payload, err := json.Marshal(map[string]string{ - "type": "read_receipt", - "conversation_id": conv.ID().String(), - "user_id": readerID.String(), - "read_at": readAt.Format(time.RFC3339Nano), - }) - if err != nil { - log.Warn().Err(err).Msg("mark read: marshal broadcast") - return - } eventID := fmt.Sprintf("read-%s-%s", conv.ID(), readerID) + resp := &iamv1.StreamChatEventsResponse{ + EventId: eventID, + Payload: &iamv1.StreamChatEventsResponse_ReadReceipt{ + ReadReceipt: &iamv1.ReadEvent{ + ConversationId: conv.ID().String(), + UserId: readerID.String(), + ReadAt: readAt.Format(time.RFC3339Nano), + }, + }, + } for _, p := range conv.Participants() { if !p.IsActive() { continue } h.broadcaster.Publish(&chatinfra.Event{ - EventID: fmt.Sprintf("%s-%s", eventID, p.UserID()), - UserID: p.UserID(), - Payload: payload, + EventID: fmt.Sprintf("%s-%s", eventID, p.UserID()), + UserID: p.UserID(), + Response: resp, }) } } diff --git a/services/iam/internal/application/chat/set_typing_handler.go b/services/iam/internal/application/chat/set_typing_handler.go index d8c9a0f5..91d327d4 100644 --- a/services/iam/internal/application/chat/set_typing_handler.go +++ b/services/iam/internal/application/chat/set_typing_handler.go @@ -2,12 +2,11 @@ package chat import ( "context" - "encoding/json" "fmt" "github.com/google/uuid" - "github.com/rs/zerolog/log" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" ) @@ -38,26 +37,26 @@ func (h *SetTypingHandler) Handle(ctx context.Context, callerID, convID uuid.UUI return fmt.Errorf("set typing: %w", err) } - payload, err := json.Marshal(map[string]any{ - "type": "typing", - "conversation_id": convID.String(), - "user_id": callerID.String(), - "user_name": callerName, - "is_typing": isTyping, - }) - if err != nil { - log.Warn().Err(err).Msg("set typing: marshal broadcast") - return nil - } eventID := fmt.Sprintf("typing-%s-%s", convID, callerID) + resp := &iamv1.StreamChatEventsResponse{ + EventId: eventID, + Payload: &iamv1.StreamChatEventsResponse_Typing{ + Typing: &iamv1.TypingEvent{ + ConversationId: convID.String(), + UserId: callerID.String(), + UserName: callerName, + IsTyping: isTyping, + }, + }, + } for _, part := range conv.Participants() { if !part.IsActive() || part.UserID() == callerID { - continue // don't echo back to sender + continue } h.broadcaster.Publish(&chatinfra.Event{ - EventID: fmt.Sprintf("%s-%s", eventID, part.UserID()), - UserID: part.UserID(), - Payload: payload, + EventID: fmt.Sprintf("%s-%s", eventID, part.UserID()), + UserID: part.UserID(), + Response: resp, }) } return nil diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index f09db4da..f21c4725 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -2,7 +2,6 @@ package grpc import ( "context" - "encoding/json" "errors" "time" @@ -14,7 +13,6 @@ import ( iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" appChat "github.com/mutugading/goapps-backend/services/iam/internal/application/chat" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" - chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" ) // ChatHandler implements iamv1.ChatServiceServer. @@ -307,8 +305,7 @@ func (h *ChatHandler) StreamChatEvents(_ *iamv1.StreamChatEventsRequest, stream if !ok { return nil } - resp := buildStreamResponse(evt) - if sendErr := stream.Send(resp); sendErr != nil { + if sendErr := stream.Send(evt.Response); sendErr != nil { return sendErr } } @@ -365,68 +362,6 @@ func chatSuccessBase() *commonv1.BaseResponse { return &commonv1.BaseResponse{IsSuccess: true, Message: "success", StatusCode: "200"} } -type broadcastPayload struct { - Type string `json:"type"` - ConversationID string `json:"conversation_id"` - MessageID string `json:"message_id"` - SenderUserID string `json:"sender_user_id"` - Body string `json:"body"` - IsEdited bool `json:"is_edited"` - IsDeleted bool `json:"is_deleted"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - IsTyping bool `json:"is_typing"` - ReadAt string `json:"read_at"` - IsOnline bool `json:"is_online"` -} - -func buildStreamResponse(evt *chatinfra.Event) *iamv1.StreamChatEventsResponse { - resp := &iamv1.StreamChatEventsResponse{EventId: evt.EventID} - var p broadcastPayload - if err := json.Unmarshal(evt.Payload, &p); err != nil { - return resp - } - msg := &iamv1.MessageProto{ - MessageId: p.MessageID, - ConversationId: p.ConversationID, - SenderUserId: p.SenderUserID, - Body: p.Body, - IsEdited: p.IsEdited, - IsDeleted: p.IsDeleted, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - } - switch p.Type { - case "message_received": - resp.Payload = &iamv1.StreamChatEventsResponse_MessageReceived{ - MessageReceived: &iamv1.MessageEvent{ConversationId: p.ConversationID, Message: msg}, - } - case "message_edited": - resp.Payload = &iamv1.StreamChatEventsResponse_MessageEdited{ - MessageEdited: &iamv1.MessageEvent{ConversationId: p.ConversationID, Message: msg}, - } - case "message_deleted": - resp.Payload = &iamv1.StreamChatEventsResponse_MessageDeleted{ - MessageDeleted: &iamv1.DeleteEvent{ConversationId: p.ConversationID, MessageId: p.MessageID}, - } - case "typing": - resp.Payload = &iamv1.StreamChatEventsResponse_Typing{ - Typing: &iamv1.TypingEvent{ConversationId: p.ConversationID, UserId: p.UserID, UserName: p.UserName, IsTyping: p.IsTyping}, - } - case "read_receipt": - resp.Payload = &iamv1.StreamChatEventsResponse_ReadReceipt{ - ReadReceipt: &iamv1.ReadEvent{ConversationId: p.ConversationID, UserId: p.UserID, ReadAt: p.ReadAt}, - } - case "presence": - resp.Payload = &iamv1.StreamChatEventsResponse_Presence{ - Presence: &iamv1.PresenceEvent{UserId: p.UserID, IsOnline: p.IsOnline}, - } - } - return resp -} - func mapChatError(err error) error { switch { case errors.Is(err, chat.ErrConversationNotFound), errors.Is(err, chat.ErrMessageNotFound): diff --git a/services/iam/internal/infrastructure/chat/broadcaster.go b/services/iam/internal/infrastructure/chat/broadcaster.go index dd061c4d..6f37010a 100644 --- a/services/iam/internal/infrastructure/chat/broadcaster.go +++ b/services/iam/internal/infrastructure/chat/broadcaster.go @@ -13,7 +13,6 @@ package chat import ( "context" - "encoding/json" "fmt" "os" "sync" @@ -21,17 +20,19 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/rs/zerolog/log" + "google.golang.org/protobuf/proto" + + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" ) const chatChannelPrefix = "iam:chat:" -// Event is a chat event fanned out to subscribers. Payload carries the -// JSON-encoded ChatEvent proto so the broadcaster stays decoupled from the -// chat domain model. +// Event is a chat event fanned out to subscribers. Response carries the +// typed proto response for zero-copy forwarding to gRPC streams. type Event struct { - EventID string - UserID uuid.UUID - Payload []byte + EventID string + UserID uuid.UUID + Response *iamv1.StreamChatEventsResponse } // Broadcaster is the in-memory event bus. @@ -135,15 +136,13 @@ func (b *Broadcaster) Publish(e *Event) { } } -// PublishToConversation fans out the same event payload to every participant -// of a conversation. eventID and payload are shared; only the recipient -// differs per participant. -func (b *Broadcaster) PublishToConversation(participantIDs []uuid.UUID, eventID string, payload []byte) { +// PublishToConversation fans out the same event to every participant. +func (b *Broadcaster) PublishToConversation(participantIDs []uuid.UUID, eventID string, resp *iamv1.StreamChatEventsResponse) { for _, userID := range participantIDs { b.Publish(&Event{ - EventID: eventID, - UserID: userID, - Payload: payload, + EventID: fmt.Sprintf("%s-%s", eventID, userID), + UserID: userID, + Response: resp, }) } } @@ -230,41 +229,46 @@ func (b *Broadcaster) SubscriberCount(recipient uuid.UUID) int { return len(b.subs[recipient]) } -// redisMsg is the JSON wire format used for Redis pub/sub cross-pod fan-out. -type redisMsg struct { - PodID string `json:"pod"` - EventID string `json:"eid"` - UserID string `json:"uid"` - Payload []byte `json:"p"` -} - func serializeEvent(e *Event, podID string) (string, error) { - msg := redisMsg{ - PodID: podID, - EventID: e.EventID, - UserID: e.UserID.String(), - Payload: e.Payload, - } - b, err := json.Marshal(msg) + protoBytes, err := proto.Marshal(e.Response) if err != nil { - return "", fmt.Errorf("marshal chat event: %w", err) + return "", fmt.Errorf("marshal proto event: %w", err) } - return string(b), nil + envelope := fmt.Sprintf("%s\x00%s\x00%s\x00", podID, e.EventID, e.UserID.String()) + return envelope + string(protoBytes), nil } func deserializeEvent(payload string) (*Event, string, error) { - var msg redisMsg - if err := json.Unmarshal([]byte(payload), &msg); err != nil { - return nil, "", fmt.Errorf("unmarshal chat event: %w", err) + parts := splitNullSep(payload, 4) + if parts == nil { + return nil, "", fmt.Errorf("invalid redis envelope") } - userID, err := uuid.Parse(msg.UserID) + podID, eventID, userIDStr, protoBytes := parts[0], parts[1], parts[2], []byte(parts[3]) + userID, err := uuid.Parse(userIDStr) if err != nil { return nil, "", fmt.Errorf("parse user id: %w", err) } - e := &Event{ - EventID: msg.EventID, - UserID: userID, - Payload: msg.Payload, + resp := &iamv1.StreamChatEventsResponse{} + if err := proto.Unmarshal(protoBytes, resp); err != nil { + return nil, "", fmt.Errorf("unmarshal proto event: %w", err) + } + resp.EventId = eventID + return &Event{EventID: eventID, UserID: userID, Response: resp}, podID, nil +} + +func splitNullSep(s string, n int) []string { + result := make([]string, 0, n) + for i := 0; i < n-1; i++ { + idx := 0 + for idx < len(s) && s[idx] != 0 { + idx++ + } + if idx >= len(s) { + return nil + } + result = append(result, s[:idx]) + s = s[idx+1:] } - return e, msg.PodID, nil + result = append(result, s) + return result } diff --git a/services/iam/internal/infrastructure/chat/broadcaster_test.go b/services/iam/internal/infrastructure/chat/broadcaster_test.go index 2d23bd92..dffc6bbc 100644 --- a/services/iam/internal/infrastructure/chat/broadcaster_test.go +++ b/services/iam/internal/infrastructure/chat/broadcaster_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" ) @@ -18,10 +19,11 @@ func TestBroadcaster_PublishReceive(t *testing.T) { ch, unsub := b.Subscribe(user) defer unsub() + resp := &iamv1.StreamChatEventsResponse{EventId: "test-event"} ev := &chatinfra.Event{ - EventID: uuid.New().String(), - UserID: user, - Payload: []byte(`{"type":"message.created"}`), + EventID: uuid.New().String(), + UserID: user, + Response: resp, } b.Publish(ev) @@ -29,7 +31,7 @@ func TestBroadcaster_PublishReceive(t *testing.T) { case got := <-ch: assert.Equal(t, ev.EventID, got.EventID) assert.Equal(t, ev.UserID, got.UserID) - assert.Equal(t, ev.Payload, got.Payload) + assert.Equal(t, "test-event", got.Response.GetEventId()) case <-time.After(100 * time.Millisecond): t.Fatal("timed out waiting for chat event") } @@ -48,9 +50,9 @@ func TestBroadcaster_MultipleSubscribers(t *testing.T) { assert.Equal(t, 2, b.SubscriberCount(user)) ev := &chatinfra.Event{ - EventID: uuid.New().String(), - UserID: user, - Payload: []byte(`{"type":"message.created"}`), + EventID: uuid.New().String(), + UserID: user, + Response: &iamv1.StreamChatEventsResponse{EventId: "multi-test"}, } b.Publish(ev) From 85bfe0d674b06d3de0ad63a6e59813691e060731 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 08:48:07 +0700 Subject: [PATCH 09/17] feat(iam): Redis Streams + user info resolver for production-grade chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redis Streams (replaces Pub/Sub): - XADD with MAXLEN ~500 per user stream (durable, trimmed) - XREAD BLOCK 5s for cross-pod event bridging - Proto binary serialization via base64 encoding in stream values - Events persist across pod restarts — replayable on reconnect User info resolver for conversation participants: - ChatUserResolver batch-resolves userIDs to {username, fullName, avatarUrl} - convToProto now populates ParticipantProto.Username/FullName/AvatarUrl - Fixes "Unknown User" / "UN" initials in conversation list Co-Authored-By: Claude Opus 4.6 (1M context) --- services/iam/cmd/server/main.go | 2 + .../internal/delivery/grpc/chat_handler.go | 31 ++- .../infrastructure/chat/broadcaster.go | 205 ++++++++---------- .../postgres/chat_user_resolver.go | 70 ++++++ 4 files changed, 183 insertions(+), 125 deletions(-) create mode 100644 services/iam/internal/infrastructure/postgres/chat_user_resolver.go diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index b39b6ac8..f76fb002 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -235,6 +235,7 @@ func run() error { chatConvRepo := postgres.NewChatConversationRepository(db) chatMsgRepo := postgres.NewChatMessageRepository(db) chatReceiptRepo := postgres.NewChatReadReceiptRepository(db) + chatUserResolver := postgres.NewChatUserResolver(db) sendMsgHandler := appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster) if presenceSvc != nil && redisClient != nil { sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, redisClient.Client) @@ -252,6 +253,7 @@ func run() error { appChat.NewMarkReadHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatBroadcaster), appChat.NewSetTypingHandler(chatConvRepo, presenceSvc, chatBroadcaster), appChat.NewStreamHandler(chatBroadcaster), + chatUserResolver, ) if presenceSvc != nil { presenceGRPCHandler = grpcdelivery.NewPresenceHandler(presenceSvc) diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index f21c4725..85a80097 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -13,6 +13,7 @@ import ( iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" appChat "github.com/mutugading/goapps-backend/services/iam/internal/application/chat" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/postgres" ) // ChatHandler implements iamv1.ChatServiceServer. @@ -30,6 +31,7 @@ type ChatHandler struct { markRead *appChat.MarkReadHandler setTyping *appChat.SetTypingHandler stream *appChat.StreamHandler + userResolver *postgres.ChatUserResolver } // NewChatHandler constructs the handler. @@ -46,13 +48,14 @@ func NewChatHandler( markRead *appChat.MarkReadHandler, setTyping *appChat.SetTypingHandler, stream *appChat.StreamHandler, + userResolver *postgres.ChatUserResolver, ) *ChatHandler { return &ChatHandler{ createDirect: createDirect, createGroup: createGroup, getConv: getConv, listConvs: listConvs, leaveConv: leaveConv, sendMsg: sendMsg, editMsg: editMsg, deleteMsg: deleteMsg, listMsgs: listMsgs, markRead: markRead, setTyping: setTyping, - stream: stream, + stream: stream, userResolver: userResolver, } } @@ -70,7 +73,7 @@ func (h *ChatHandler) CreateDirectConversation(ctx context.Context, req *iamv1.C if err != nil { return nil, mapChatError(err) } - return &iamv1.CreateDirectConversationResponse{Base: chatSuccessBase(), Data: convToProto(conv)}, nil + return &iamv1.CreateDirectConversationResponse{Base: chatSuccessBase(), Data: h.convToProto(ctx, conv)}, nil } // CreateGroupConversation creates a group conversation. @@ -91,7 +94,7 @@ func (h *ChatHandler) CreateGroupConversation(ctx context.Context, req *iamv1.Cr if err != nil { return nil, mapChatError(err) } - return &iamv1.CreateGroupConversationResponse{Base: chatSuccessBase(), Data: convToProto(conv)}, nil + return &iamv1.CreateGroupConversationResponse{Base: chatSuccessBase(), Data: h.convToProto(ctx, conv)}, nil } // GetConversation returns a single conversation. @@ -108,7 +111,7 @@ func (h *ChatHandler) GetConversation(ctx context.Context, req *iamv1.GetConvers if err != nil { return nil, mapChatError(err) } - return &iamv1.GetConversationResponse{Base: chatSuccessBase(), Data: convToProto(conv)}, nil + return &iamv1.GetConversationResponse{Base: chatSuccessBase(), Data: h.convToProto(ctx, conv)}, nil } // ListConversations returns paginated conversations. @@ -130,7 +133,7 @@ func (h *ChatHandler) ListConversations(ctx context.Context, req *iamv1.ListConv } protos := make([]*iamv1.ConversationProto, 0, len(result.Conversations)) for _, c := range result.Conversations { - protos = append(protos, convToProto(c)) + protos = append(protos, h.convToProto(ctx, c)) } totalPages := int32((result.Total + int64(pageSize) - 1) / int64(pageSize)) //nolint:gosec // bounded by page count return &iamv1.ListConversationsResponse{ @@ -312,14 +315,26 @@ func (h *ChatHandler) StreamChatEvents(_ *iamv1.StreamChatEventsRequest, stream } } -func convToProto(conv *chat.Conversation) *iamv1.ConversationProto { +func (h *ChatHandler) convToProto(ctx context.Context, conv *chat.Conversation) *iamv1.ConversationProto { + userIDs := make([]uuid.UUID, 0, len(conv.Participants())) + for _, p := range conv.Participants() { + userIDs = append(userIDs, p.UserID()) + } + userMap, _ := h.userResolver.ResolveUsers(ctx, userIDs) + parts := make([]*iamv1.ParticipantProto, 0, len(conv.Participants())) for _, p := range conv.Participants() { - parts = append(parts, &iamv1.ParticipantProto{ + pp := &iamv1.ParticipantProto{ UserId: p.UserID().String(), Role: p.Role().String(), JoinedAt: p.JoinedAt().UTC().Format(time.RFC3339Nano), - }) + } + if info := userMap[p.UserID()]; info != nil { + pp.Username = info.Username + pp.FullName = info.FullName + pp.AvatarUrl = info.AvatarURL + } + parts = append(parts, pp) } return &iamv1.ConversationProto{ ConversationId: conv.ID().String(), diff --git a/services/iam/internal/infrastructure/chat/broadcaster.go b/services/iam/internal/infrastructure/chat/broadcaster.go index 6f37010a..24765b6a 100644 --- a/services/iam/internal/infrastructure/chat/broadcaster.go +++ b/services/iam/internal/infrastructure/chat/broadcaster.go @@ -1,21 +1,24 @@ -// Package chat provides in-memory pub/sub fan-out and Redis-backed presence -// tracking for the chat stream. +// Package chat provides in-memory + Redis Streams fan-out for chat events and +// Redis TTL-based presence tracking. // // Each authenticated user can have multiple subscribers (open SSE streams in -// different tabs). Publish fans out to all of a recipient's in-memory subscribers. +// different tabs). Publish fans out to all of a recipient's in-memory +// subscribers, then writes to a per-user Redis Stream so other IAM pods +// (and reconnecting clients) can replay missed events. // -// When a Redis client is provided (via NewRedisBroadcaster), events are also -// published to a Redis pub/sub channel so that other IAM pods pick them up. -// This allows HPA to scale IAM to multiple replicas without losing SSE events. -// Same-pod events are deduplicated via the originating pod ID embedded in the -// Redis message, so subscribers receive each event exactly once. +// Redis Streams (XADD/XREAD) replace the previous Pub/Sub approach: +// events are durable (trimmed to last 500 per user), replayable via +// lastEventId, and support multi-pod consumer reads without consumer groups +// (each pod reads independently using its own cursor). package chat import ( "context" + "encoding/base64" "fmt" "os" "sync" + "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" @@ -25,38 +28,35 @@ import ( iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" ) -const chatChannelPrefix = "iam:chat:" +const ( + chatStreamPrefix = "iam:chatstream:" + streamMaxLen = 500 + streamBlockTime = 5 * time.Second +) -// Event is a chat event fanned out to subscribers. Response carries the -// typed proto response for zero-copy forwarding to gRPC streams. +// Event is a chat event fanned out to subscribers. type Event struct { EventID string UserID uuid.UUID Response *iamv1.StreamChatEventsResponse } -// Broadcaster is the in-memory event bus. -// -// For multi-replica IAM deployments, construct it with NewRedisBroadcaster so -// that events published on one pod are received by all pods via Redis pub/sub. -// For single-replica deployments, NewBroadcaster (in-memory only) is sufficient. +// Broadcaster fans out chat events via in-memory channels and Redis Streams. type Broadcaster struct { mu sync.RWMutex subs map[uuid.UUID]map[chan *Event]struct{} - rdb *redis.Client // nil → in-memory only + rdb *redis.Client selfPodID string } -// NewBroadcaster returns a fresh in-memory-only Broadcaster. +// NewBroadcaster returns an in-memory-only Broadcaster. func NewBroadcaster() *Broadcaster { return &Broadcaster{ subs: make(map[uuid.UUID]map[chan *Event]struct{}), } } -// NewRedisBroadcaster returns a Broadcaster backed by Redis pub/sub for -// cross-pod fan-out. Pass the same *redis.Client used for the session cache. -// If rdb is nil, falls back to in-memory behavior. +// NewRedisBroadcaster returns a Broadcaster backed by Redis Streams. func NewRedisBroadcaster(rdb *redis.Client) *Broadcaster { return &Broadcaster{ subs: make(map[uuid.UUID]map[chan *Event]struct{}), @@ -65,7 +65,6 @@ func NewRedisBroadcaster(rdb *redis.Client) *Broadcaster { } } -// resolvePodID returns the K8s pod name (os.Hostname) or a random UUID fallback. func resolvePodID() string { h, err := os.Hostname() if err != nil || h == "" { @@ -74,15 +73,10 @@ func resolvePodID() string { return h } -// Subscribe registers a new subscriber for recipient. -// Returns the receive channel and an unsubscribe function the caller must invoke -// (typically via defer) when the stream ends. -// -// If a Redis client is configured, a background goroutine bridges events from -// other pods into the returned channel. Events from this pod are excluded to -// prevent duplicates (they arrive via the in-memory path already). +// Subscribe registers a subscriber for a user and returns the event channel +// and an unsubscribe function. func (b *Broadcaster) Subscribe(recipient uuid.UUID) (<-chan *Event, func()) { - const bufferSize = 16 + const bufferSize = 32 ch := make(chan *Event, bufferSize) b.mu.Lock() @@ -93,20 +87,17 @@ func (b *Broadcaster) Subscribe(recipient uuid.UUID) (<-chan *Event, func()) { b.mu.Unlock() unsubMem := b.makeUnsubFunc(recipient, ch) - if b.rdb == nil { return ch, unsubMem } - cancelRedis := b.bridgeRedis(recipient, ch) + cancelRedis := b.bridgeRedisStream(recipient, ch) return ch, func() { cancelRedis() unsubMem() } } -// makeUnsubFunc returns a function that removes ch from the in-memory subscriber -// set for recipient and closes the channel. func (b *Broadcaster) makeUnsubFunc(recipient uuid.UUID, ch chan *Event) func() { return func() { b.mu.Lock() @@ -123,16 +114,14 @@ func (b *Broadcaster) makeUnsubFunc(recipient uuid.UUID, ch chan *Event) func() } } -// Publish fans out an event to all in-memory subscribers of its recipient, -// then publishes to the Redis channel so other pods can forward it to their -// in-memory subscribers. Non-blocking: slow consumers drop the event. +// Publish fans out an event in-memory and writes to Redis Stream. func (b *Broadcaster) Publish(e *Event) { if e == nil { return } b.publishInMemory(e) if b.rdb != nil { - b.publishRedis(e) + b.publishToStream(e) } } @@ -147,6 +136,13 @@ func (b *Broadcaster) PublishToConversation(participantIDs []uuid.UUID, eventID } } +// SubscriberCount returns the number of in-memory subscribers for a user. +func (b *Broadcaster) SubscriberCount(recipient uuid.UUID) int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subs[recipient]) +} + func (b *Broadcaster) publishInMemory(e *Event) { b.mu.RLock() defer b.mu.RUnlock() @@ -158,47 +154,64 @@ func (b *Broadcaster) publishInMemory(e *Event) { select { case ch <- e: default: - // drop — subscriber will resync via DB on reconnect } } } -func (b *Broadcaster) publishRedis(e *Event) { - payload, err := serializeEvent(e, b.selfPodID) +func (b *Broadcaster) publishToStream(e *Event) { + protoBytes, err := proto.Marshal(e.Response) if err != nil { - log.Warn().Err(err).Msg("broadcaster: failed to serialize chat event for Redis pub/sub") + log.Warn().Err(err).Msg("broadcaster: marshal proto for stream") return } - channel := chatChannelPrefix + e.UserID.String() - if err := b.rdb.Publish(context.Background(), channel, payload).Err(); err != nil { - log.Warn().Err(err).Msg("broadcaster: failed to publish chat event to Redis") + streamKey := chatStreamPrefix + e.UserID.String() + err = b.rdb.XAdd(context.Background(), &redis.XAddArgs{ + Stream: streamKey, + MaxLen: streamMaxLen, + Approx: true, + Values: map[string]any{ + "eid": e.EventID, + "pod": b.selfPodID, + "data": base64.StdEncoding.EncodeToString(protoBytes), + }, + }).Err() + if err != nil { + log.Warn().Err(err).Msg("broadcaster: XADD to Redis Stream") } } -// bridgeRedis subscribes to the Redis pub/sub channel for recipient and -// forwards events from other pods into ch. Returns a cancel function that -// terminates the bridge goroutine. -func (b *Broadcaster) bridgeRedis(recipient uuid.UUID, ch chan *Event) context.CancelFunc { +// bridgeRedisStream reads from a per-user Redis Stream and forwards events +// from other pods into ch. Starts reading from "$" (new events only). +func (b *Broadcaster) bridgeRedisStream(recipient uuid.UUID, ch chan *Event) context.CancelFunc { ctx, cancel := context.WithCancel(context.Background()) - channel := chatChannelPrefix + recipient.String() - pubsub := b.rdb.Subscribe(ctx, channel) + streamKey := chatStreamPrefix + recipient.String() go func() { - defer func() { - if err := pubsub.Close(); err != nil { - log.Warn().Err(err).Msg("broadcaster: Redis pubsub close error") - } - }() - msgCh := pubsub.Channel() + lastID := "$" for { select { case <-ctx.Done(): return - case msg, ok := <-msgCh: - if !ok { - return + default: + } + streams, err := b.rdb.XRead(ctx, &redis.XReadArgs{ + Streams: []string{streamKey, lastID}, + Block: streamBlockTime, + Count: 50, + }).Result() + if err != nil { + if err == redis.Nil || ctx.Err() != nil { + continue + } + log.Warn().Err(err).Msg("broadcaster: XREAD error") + time.Sleep(time.Second) + continue + } + for _, stream := range streams { + for _, msg := range stream.Messages { + lastID = msg.ID + b.handleStreamMessage(msg, recipient, ch) } - b.handleRedisMessage(msg.Payload, ch) } } }() @@ -206,69 +219,27 @@ func (b *Broadcaster) bridgeRedis(recipient uuid.UUID, ch chan *Event) context.C return cancel } -func (b *Broadcaster) handleRedisMessage(payload string, ch chan *Event) { - e, podID, err := deserializeEvent(payload) - if err != nil { - log.Warn().Err(err).Msg("broadcaster: failed to deserialize Redis chat event") - return - } +func (b *Broadcaster) handleStreamMessage(msg redis.XMessage, userID uuid.UUID, ch chan *Event) { + podID, _ := msg.Values["pod"].(string) if podID == b.selfPodID { - return // already delivered via in-memory fan-out - } - select { - case ch <- e: - default: - // drop — subscriber will resync via DB on reconnect - } -} - -// SubscriberCount returns the total number of in-memory subscribers for recipient. -func (b *Broadcaster) SubscriberCount(recipient uuid.UUID) int { - b.mu.RLock() - defer b.mu.RUnlock() - return len(b.subs[recipient]) -} - -func serializeEvent(e *Event, podID string) (string, error) { - protoBytes, err := proto.Marshal(e.Response) - if err != nil { - return "", fmt.Errorf("marshal proto event: %w", err) - } - envelope := fmt.Sprintf("%s\x00%s\x00%s\x00", podID, e.EventID, e.UserID.String()) - return envelope + string(protoBytes), nil -} - -func deserializeEvent(payload string) (*Event, string, error) { - parts := splitNullSep(payload, 4) - if parts == nil { - return nil, "", fmt.Errorf("invalid redis envelope") + return } - podID, eventID, userIDStr, protoBytes := parts[0], parts[1], parts[2], []byte(parts[3]) - userID, err := uuid.Parse(userIDStr) + eventID, _ := msg.Values["eid"].(string) + dataB64, _ := msg.Values["data"].(string) + protoBytes, err := base64.StdEncoding.DecodeString(dataB64) if err != nil { - return nil, "", fmt.Errorf("parse user id: %w", err) + log.Warn().Err(err).Msg("broadcaster: decode stream data") + return } resp := &iamv1.StreamChatEventsResponse{} if err := proto.Unmarshal(protoBytes, resp); err != nil { - return nil, "", fmt.Errorf("unmarshal proto event: %w", err) + log.Warn().Err(err).Msg("broadcaster: unmarshal stream proto") + return } resp.EventId = eventID - return &Event{EventID: eventID, UserID: userID, Response: resp}, podID, nil -} - -func splitNullSep(s string, n int) []string { - result := make([]string, 0, n) - for i := 0; i < n-1; i++ { - idx := 0 - for idx < len(s) && s[idx] != 0 { - idx++ - } - if idx >= len(s) { - return nil - } - result = append(result, s[:idx]) - s = s[idx+1:] + evt := &Event{EventID: eventID, UserID: userID, Response: resp} + select { + case ch <- evt: + default: } - result = append(result, s) - return result } diff --git a/services/iam/internal/infrastructure/postgres/chat_user_resolver.go b/services/iam/internal/infrastructure/postgres/chat_user_resolver.go new file mode 100644 index 00000000..7353a5ac --- /dev/null +++ b/services/iam/internal/infrastructure/postgres/chat_user_resolver.go @@ -0,0 +1,70 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// ChatUserInfo holds lightweight user data for chat participant display. +type ChatUserInfo struct { + UserID uuid.UUID + Username string + FullName string + AvatarURL string +} + +// ChatUserResolver resolves user IDs to display info for chat participants. +type ChatUserResolver struct { + db *DB +} + +// NewChatUserResolver constructs the resolver. +func NewChatUserResolver(db *DB) *ChatUserResolver { + return &ChatUserResolver{db: db} +} + +// ResolveUsers batch-resolves user IDs to display info. +func (r *ChatUserResolver) ResolveUsers(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]*ChatUserInfo, error) { + if len(userIDs) == 0 { + return nil, nil + } + placeholders := make([]string, len(userIDs)) + args := make([]any, len(userIDs)) + for i, id := range userIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + q := fmt.Sprintf(` + SELECT u.user_id, u.username, + COALESCE(d.full_name, ''), + COALESCE(d.profile_picture_url, '') + FROM mst_user u + LEFT JOIN mst_user_detail d ON d.user_id = u.user_id + WHERE u.user_id IN (%s)`, strings.Join(placeholders, ",")) + + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("chat user resolver: query: %w", err) + } + defer rows.Close() + + result := make(map[uuid.UUID]*ChatUserInfo, len(userIDs)) + for rows.Next() { + var info ChatUserInfo + var fullName, avatarURL sql.NullString + if err := rows.Scan(&info.UserID, &info.Username, &fullName, &avatarURL); err != nil { + return nil, fmt.Errorf("chat user resolver: scan: %w", err) + } + info.FullName = fullName.String + info.AvatarURL = avatarURL.String + result[info.UserID] = &info + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat user resolver: rows err: %w", err) + } + return result, nil +} From 54e778f491bc3839d9c9f19810a9190907182c53 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 09:26:52 +0700 Subject: [PATCH 10/17] fix(iam): graceful handling of master key mismatch + log unhandled errors ListMessages: if conversation key decryption fails (master key changed between sessions), return empty list instead of 500 error. Logs the mismatch as a warning. mapChatError: log the actual error before returning "internal error" so backend logs show the root cause. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../iam/internal/application/chat/list_messages_handler.go | 4 +++- services/iam/internal/delivery/grpc/chat_handler.go | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/services/iam/internal/application/chat/list_messages_handler.go b/services/iam/internal/application/chat/list_messages_handler.go index bca35de6..425e83a5 100644 --- a/services/iam/internal/application/chat/list_messages_handler.go +++ b/services/iam/internal/application/chat/list_messages_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" @@ -48,7 +49,8 @@ func (h *ListMessagesHandler) Handle(ctx context.Context, callerID, convID uuid. convKeyPlain, err := h.enc.DecryptConversationKey(conv.EncryptionKey()) if err != nil { - return nil, fmt.Errorf("list messages: decrypt conv key: %w", err) + log.Warn().Err(err).Str("conv", convID.String()).Msg("list messages: decrypt conv key failed (master key may have changed)") + return &MessagesResult{Messages: []*DecryptedMessage{}, NextCursor: "", HasMore: false}, nil } msgs, nextCursor, hasMore, err := h.msgRepo.ListByConversation(ctx, convID, pageSize, beforeCursor) diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index 85a80097..a4490cc1 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -6,6 +6,7 @@ import ( "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -386,6 +387,7 @@ func mapChatError(err error) error { case errors.Is(err, chat.ErrDirectConversationFull), errors.Is(err, chat.ErrAlreadyParticipant): return status.Errorf(codes.FailedPrecondition, "%v", err) default: + log.Error().Err(err).Msg("chat handler: unhandled error") return status.Errorf(codes.Internal, "internal error") } } From 84e62d508cc5a3c147ed184dfe87b41ba6cecc3c Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 09:45:32 +0700 Subject: [PATCH 11/17] fix(iam): dispatch email to offline chat recipients via EmailDispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateHandler only persists + SSE-broadcasts notifications — it never sends email. Added EmailDispatcher to SendMessageHandler and call Dispatch() in a background goroutine after creating the offline notification. Wired notifEmailDispatcher from main.go. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/iam/cmd/server/main.go | 2 +- .../application/chat/send_message_handler.go | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index f76fb002..e62d78f2 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -238,7 +238,7 @@ func run() error { chatUserResolver := postgres.NewChatUserResolver(db) sendMsgHandler := appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster) if presenceSvc != nil && redisClient != nil { - sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, redisClient.Client) + sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, notifEmailDispatcher, redisClient.Client) } chatHandler = grpcdelivery.NewChatHandler( appChat.NewCreateDirectHandler(chatConvRepo, chatEnc), diff --git a/services/iam/internal/application/chat/send_message_handler.go b/services/iam/internal/application/chat/send_message_handler.go index 587ea005..81c7934b 100644 --- a/services/iam/internal/application/chat/send_message_handler.go +++ b/services/iam/internal/application/chat/send_message_handler.go @@ -23,9 +23,10 @@ type SendMessageHandler struct { receiptRepo chat.ReadReceiptRepository enc *crypto.Encryptor broadcaster *chatinfra.Broadcaster - presence *chatinfra.PresenceService - notifCreate *appnotif.CreateHandler - rdb *redis.Client + presence *chatinfra.PresenceService + notifCreate *appnotif.CreateHandler + emailDispatch appnotif.EmailDispatcher + rdb *redis.Client } // NewSendMessageHandler constructs the handler. @@ -40,9 +41,10 @@ func NewSendMessageHandler( } // WithOfflineNotification enables email notifications for offline users. -func (h *SendMessageHandler) WithOfflineNotification(presence *chatinfra.PresenceService, notifCreate *appnotif.CreateHandler, rdb *redis.Client) *SendMessageHandler { +func (h *SendMessageHandler) WithOfflineNotification(presence *chatinfra.PresenceService, notifCreate *appnotif.CreateHandler, emailDispatch appnotif.EmailDispatcher, rdb *redis.Client) *SendMessageHandler { h.presence = presence h.notifCreate = notifCreate + h.emailDispatch = emailDispatch h.rdb = rdb return h } @@ -118,7 +120,7 @@ func (h *SendMessageHandler) notifyIfOffline(ctx context.Context, recipientID uu if h.isDebounced(ctx, msg.ConversationID(), recipientID) { return } - _, err = h.notifCreate.Handle(ctx, appnotif.CreateCommand{ + n, err := h.notifCreate.Handle(ctx, appnotif.CreateCommand{ RecipientUserID: recipientID, Type: notification.TypeChat, Severity: notification.SeverityInfo, @@ -132,6 +134,10 @@ func (h *SendMessageHandler) notifyIfOffline(ctx context.Context, recipientID uu }) if err != nil { log.Warn().Err(err).Msg("send message: create offline notification") + return + } + if h.emailDispatch != nil && n != nil { + go h.emailDispatch.Dispatch(context.Background(), n) } } From a2f20817aa08c0530168f88fcc257959cf8d2d56 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 10:09:05 +0700 Subject: [PATCH 12/17] feat(iam): sender name in broadcasts + email with sender context - broadcastMessageEvent: accept senderName param, set MessageProto.SenderName - resolveSenderName helper: shared between send + edit handlers - SendMessage: resolve sender name before broadcast + offline notification - EditMessage: resolve original sender name for edit broadcast - Offline email: title="{name} sent you a message", body="{name}: {text}", CTA label="Reply" pointing to /chat - WithOfflineNotification: accept ChatUserResolver for name resolution Co-Authored-By: Claude Opus 4.6 (1M context) --- services/iam/cmd/server/main.go | 4 +- .../application/chat/broadcast_helpers.go | 28 ++++++++++- .../application/chat/edit_message_handler.go | 17 ++++--- .../application/chat/send_message_handler.go | 46 +++++++++++-------- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index e62d78f2..70b9407e 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -238,7 +238,7 @@ func run() error { chatUserResolver := postgres.NewChatUserResolver(db) sendMsgHandler := appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster) if presenceSvc != nil && redisClient != nil { - sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, notifEmailDispatcher, redisClient.Client) + sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, notifEmailDispatcher, redisClient.Client, chatUserResolver) } chatHandler = grpcdelivery.NewChatHandler( appChat.NewCreateDirectHandler(chatConvRepo, chatEnc), @@ -247,7 +247,7 @@ func run() error { appChat.NewListConversationsHandler(chatConvRepo), appChat.NewLeaveConversationHandler(chatConvRepo), sendMsgHandler, - appChat.NewEditMessageHandler(chatConvRepo, chatMsgRepo, chatEnc, chatBroadcaster), + appChat.NewEditMessageHandler(chatConvRepo, chatMsgRepo, chatEnc, chatBroadcaster, chatUserResolver), appChat.NewDeleteMessageHandler(chatConvRepo, chatMsgRepo, chatBroadcaster), appChat.NewListMessagesHandler(chatConvRepo, chatMsgRepo, chatEnc), appChat.NewMarkReadHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatBroadcaster), diff --git a/services/iam/internal/application/chat/broadcast_helpers.go b/services/iam/internal/application/chat/broadcast_helpers.go index a9bf8ee4..399da077 100644 --- a/services/iam/internal/application/chat/broadcast_helpers.go +++ b/services/iam/internal/application/chat/broadcast_helpers.go @@ -1,20 +1,46 @@ package chat import ( + "context" "fmt" "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" domainChat "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/postgres" ) +// resolveSenderName looks up a display name for senderID via userResolver, +// falling back to username, then an empty string if the resolver is nil or +// the lookup fails. +func resolveSenderName(ctx context.Context, userResolver *postgres.ChatUserResolver, senderID uuid.UUID) string { + if userResolver == nil { + return "" + } + infos, err := userResolver.ResolveUsers(ctx, []uuid.UUID{senderID}) + if err != nil { + log.Warn().Err(err).Str("user", senderID.String()).Msg("chat: resolve sender name") + return "" + } + info := infos[senderID] + if info == nil { + return "" + } + if info.FullName != "" { + return info.FullName + } + return info.Username +} + // broadcastMessageEvent publishes a message-related event (created, edited) to // every active participant of conv via the given broadcaster. -func broadcastMessageEvent(broadcaster *chatinfra.Broadcaster, conv *domainChat.Conversation, msg *domainChat.Message, plainBody, eventType string) { +func broadcastMessageEvent(broadcaster *chatinfra.Broadcaster, conv *domainChat.Conversation, msg *domainChat.Message, plainBody, eventType, senderName string) { msgProto := domainMsgToProto(msg, plainBody) + msgProto.SenderName = senderName eventID := fmt.Sprintf("msg-%s", msg.MessageID()) var resp iamv1.StreamChatEventsResponse diff --git a/services/iam/internal/application/chat/edit_message_handler.go b/services/iam/internal/application/chat/edit_message_handler.go index 787eeaaa..5634f687 100644 --- a/services/iam/internal/application/chat/edit_message_handler.go +++ b/services/iam/internal/application/chat/edit_message_handler.go @@ -11,19 +11,21 @@ import ( "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/postgres" ) // EditMessageHandler handles message edits. type EditMessageHandler struct { - convRepo chat.ConversationRepository - msgRepo chat.MessageRepository - enc *crypto.Encryptor - broadcaster *chatinfra.Broadcaster + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + enc *crypto.Encryptor + broadcaster *chatinfra.Broadcaster + userResolver *postgres.ChatUserResolver } // NewEditMessageHandler constructs the handler. -func NewEditMessageHandler(convRepo chat.ConversationRepository, msgRepo chat.MessageRepository, enc *crypto.Encryptor, broadcaster *chatinfra.Broadcaster) *EditMessageHandler { - return &EditMessageHandler{convRepo: convRepo, msgRepo: msgRepo, enc: enc, broadcaster: broadcaster} +func NewEditMessageHandler(convRepo chat.ConversationRepository, msgRepo chat.MessageRepository, enc *crypto.Encryptor, broadcaster *chatinfra.Broadcaster, userResolver *postgres.ChatUserResolver) *EditMessageHandler { + return &EditMessageHandler{convRepo: convRepo, msgRepo: msgRepo, enc: enc, broadcaster: broadcaster, userResolver: userResolver} } // Handle edits a message. Only the author can edit; saves edit history. @@ -70,6 +72,7 @@ func (h *EditMessageHandler) Handle(ctx context.Context, callerID, convID, msgID if err := h.msgRepo.UpdateBody(ctx, msg); err != nil { return nil, fmt.Errorf("edit message: update: %w", err) } - broadcastMessageEvent(h.broadcaster, conv, msg, newBody, "message_edited") + senderName := resolveSenderName(ctx, h.userResolver, msg.SenderUserID()) + broadcastMessageEvent(h.broadcaster, conv, msg, newBody, "message_edited", senderName) return msg, nil } diff --git a/services/iam/internal/application/chat/send_message_handler.go b/services/iam/internal/application/chat/send_message_handler.go index 81c7934b..e8d15ff5 100644 --- a/services/iam/internal/application/chat/send_message_handler.go +++ b/services/iam/internal/application/chat/send_message_handler.go @@ -14,19 +14,21 @@ import ( "github.com/mutugading/goapps-backend/services/iam/internal/domain/notification" chatinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/chat" "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/postgres" ) // SendMessageHandler sends a new message to a conversation. type SendMessageHandler struct { - convRepo chat.ConversationRepository - msgRepo chat.MessageRepository - receiptRepo chat.ReadReceiptRepository - enc *crypto.Encryptor - broadcaster *chatinfra.Broadcaster - presence *chatinfra.PresenceService - notifCreate *appnotif.CreateHandler - emailDispatch appnotif.EmailDispatcher - rdb *redis.Client + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + receiptRepo chat.ReadReceiptRepository + enc *crypto.Encryptor + broadcaster *chatinfra.Broadcaster + presence *chatinfra.PresenceService + notifCreate *appnotif.CreateHandler + emailDispatch appnotif.EmailDispatcher + rdb *redis.Client + userResolver *postgres.ChatUserResolver } // NewSendMessageHandler constructs the handler. @@ -41,11 +43,12 @@ func NewSendMessageHandler( } // WithOfflineNotification enables email notifications for offline users. -func (h *SendMessageHandler) WithOfflineNotification(presence *chatinfra.PresenceService, notifCreate *appnotif.CreateHandler, emailDispatch appnotif.EmailDispatcher, rdb *redis.Client) *SendMessageHandler { +func (h *SendMessageHandler) WithOfflineNotification(presence *chatinfra.PresenceService, notifCreate *appnotif.CreateHandler, emailDispatch appnotif.EmailDispatcher, rdb *redis.Client, userResolver *postgres.ChatUserResolver) *SendMessageHandler { h.presence = presence h.notifCreate = notifCreate h.emailDispatch = emailDispatch h.rdb = rdb + h.userResolver = userResolver return h } @@ -84,15 +87,16 @@ func (h *SendMessageHandler) Handle(ctx context.Context, senderID, convID uuid.U log.Warn().Err(err).Msg("send message: auto read receipt failed") } - broadcastMessageEvent(h.broadcaster, conv, msg, body, "message_received") + senderName := resolveSenderName(ctx, h.userResolver, senderID) + broadcastMessageEvent(h.broadcaster, conv, msg, body, "message_received", senderName) - h.notifyOfflineParticipants(ctx, conv, msg, senderID, body) + h.notifyOfflineParticipants(ctx, conv, msg, senderID, senderName, body) return msg, nil } const emailDebounceTTL = 5 * time.Minute -func (h *SendMessageHandler) notifyOfflineParticipants(ctx context.Context, conv *chat.Conversation, msg *chat.Message, senderID uuid.UUID, body string) { +func (h *SendMessageHandler) notifyOfflineParticipants(ctx context.Context, conv *chat.Conversation, msg *chat.Message, senderID uuid.UUID, senderName, body string) { if h.presence == nil || h.notifCreate == nil { return } @@ -104,11 +108,11 @@ func (h *SendMessageHandler) notifyOfflineParticipants(ctx context.Context, conv if !p.IsActive() || p.UserID() == senderID { continue } - h.notifyIfOffline(ctx, p.UserID(), msg, senderID, truncatedBody) + h.notifyIfOffline(ctx, p.UserID(), msg, senderID, senderName, truncatedBody) } } -func (h *SendMessageHandler) notifyIfOffline(ctx context.Context, recipientID uuid.UUID, msg *chat.Message, senderID uuid.UUID, truncatedBody string) { +func (h *SendMessageHandler) notifyIfOffline(ctx context.Context, recipientID uuid.UUID, msg *chat.Message, senderID uuid.UUID, senderName, truncatedBody string) { online, err := h.presence.IsOnline(ctx, recipientID) if err != nil { log.Warn().Err(err).Str("user", recipientID.String()).Msg("send message: check online status") @@ -120,14 +124,20 @@ func (h *SendMessageHandler) notifyIfOffline(ctx context.Context, recipientID uu if h.isDebounced(ctx, msg.ConversationID(), recipientID) { return } + title := "New chat message" + body := truncatedBody + if senderName != "" { + title = fmt.Sprintf("%s sent you a message", senderName) + body = fmt.Sprintf("%s: %s", senderName, truncatedBody) + } n, err := h.notifCreate.Handle(ctx, appnotif.CreateCommand{ RecipientUserID: recipientID, Type: notification.TypeChat, Severity: notification.SeverityInfo, - Title: "New chat message", - Body: truncatedBody, + Title: title, + Body: body, ActionType: notification.ActionNavigate, - ActionPayload: `{"url":"/chat"}`, + ActionPayload: `{"url":"/chat","label":"Reply"}`, SourceType: "chat_message", SourceID: msg.MessageID().String(), CreatedBy: senderID.String(), From 3cd8b020c9a8811e120394e4fd52520735e89c07 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 11:16:20 +0700 Subject: [PATCH 13/17] fix(iam): read receipts + lastMessage/unreadCount + edit history + email CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug A: Read receipts in ListMessages — batch-load receipts for all messages in one query (no N+1), attach to each message before return. Bug B: LastMessage + UnreadCount in conversation list — new repo methods GetUnreadCounts + GetLastMessages (batched), ListConversationsHandler returns ConversationSummary with decrypted last message body + count. Frontend conversation list now shows persistent previews from API. Bug C: GetMessageEditHistory — new application handler + gRPC method. Verifies caller is participant, decrypts old message bodies from chat_message_edit_history table. Bug D: Email CTA — ActionPayload key fixed from "url" to "path" to match NotificationDispatcher.buildCTAURL expected contract. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/iam/cmd/server/main.go | 3 +- .../application/chat/broadcast_helpers.go | 4 + .../chat/get_edit_history_handler.go | 64 ++++++++++ .../chat/list_conversations_handler.go | 61 +++++++++- .../application/chat/list_messages_handler.go | 2 +- .../application/chat/send_message_handler.go | 2 +- .../internal/delivery/grpc/chat_handler.go | 51 +++++++- .../iam/internal/domain/chat/repository.go | 8 ++ .../postgres/chat_conversation_repository.go | 72 ++++++++++- .../postgres/chat_message_repository.go | 112 +++++++++++++++++- 10 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 services/iam/internal/application/chat/get_edit_history_handler.go diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index 70b9407e..1882264b 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -244,7 +244,7 @@ func run() error { appChat.NewCreateDirectHandler(chatConvRepo, chatEnc), appChat.NewCreateGroupHandler(chatConvRepo, chatEnc), appChat.NewGetConversationHandler(chatConvRepo), - appChat.NewListConversationsHandler(chatConvRepo), + appChat.NewListConversationsHandler(chatConvRepo, chatMsgRepo, chatEnc), appChat.NewLeaveConversationHandler(chatConvRepo), sendMsgHandler, appChat.NewEditMessageHandler(chatConvRepo, chatMsgRepo, chatEnc, chatBroadcaster, chatUserResolver), @@ -253,6 +253,7 @@ func run() error { appChat.NewMarkReadHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatBroadcaster), appChat.NewSetTypingHandler(chatConvRepo, presenceSvc, chatBroadcaster), appChat.NewStreamHandler(chatBroadcaster), + appChat.NewGetEditHistoryHandler(chatConvRepo, chatMsgRepo, chatEnc), chatUserResolver, ) if presenceSvc != nil { diff --git a/services/iam/internal/application/chat/broadcast_helpers.go b/services/iam/internal/application/chat/broadcast_helpers.go index 399da077..5e65d86d 100644 --- a/services/iam/internal/application/chat/broadcast_helpers.go +++ b/services/iam/internal/application/chat/broadcast_helpers.go @@ -14,6 +14,10 @@ import ( "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/postgres" ) +// decryptionErrorBody is the placeholder body shown when a message body +// fails to decrypt (e.g. master key rotated out from under an old message). +const decryptionErrorBody = "[decryption error]" + // resolveSenderName looks up a display name for senderID via userResolver, // falling back to username, then an empty string if the resolver is nil or // the lookup fails. diff --git a/services/iam/internal/application/chat/get_edit_history_handler.go b/services/iam/internal/application/chat/get_edit_history_handler.go new file mode 100644 index 00000000..9050b842 --- /dev/null +++ b/services/iam/internal/application/chat/get_edit_history_handler.go @@ -0,0 +1,64 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" +) + +// GetEditHistoryHandler returns the decrypted edit history for a message. +type GetEditHistoryHandler struct { + convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + enc *crypto.Encryptor +} + +// NewGetEditHistoryHandler constructs the handler. +func NewGetEditHistoryHandler(convRepo chat.ConversationRepository, msgRepo chat.MessageRepository, enc *crypto.Encryptor) *GetEditHistoryHandler { + return &GetEditHistoryHandler{convRepo: convRepo, msgRepo: msgRepo, enc: enc} +} + +// EditHistoryEntryResult pairs an edit history entry with its decrypted body. +type EditHistoryEntryResult struct { + *chat.EditHistoryEntry + PlainBody string +} + +// Handle returns the edit history for messageID, newest first. callerID must +// be an active participant of convID. +func (h *GetEditHistoryHandler) Handle(ctx context.Context, callerID, convID, messageID uuid.UUID) ([]*EditHistoryEntryResult, error) { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return nil, err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return nil, fmt.Errorf("get edit history: %w", chat.ErrNotParticipant) + } + + entries, err := h.msgRepo.GetEditHistory(ctx, messageID) + if err != nil { + return nil, fmt.Errorf("get edit history: %w", err) + } + + convKeyPlain, err := h.enc.DecryptConversationKey(conv.EncryptionKey()) + if err != nil { + log.Warn().Err(err).Str("conv", convID.String()).Msg("get edit history: decrypt conv key failed") + return nil, fmt.Errorf("get edit history: decrypt conv key: %w", err) + } + + results := make([]*EditHistoryEntryResult, 0, len(entries)) + for _, entry := range entries { + plain, decErr := h.enc.DecryptMessage(convKeyPlain, entry.BodyEncrypted()) + if decErr != nil { + plain = decryptionErrorBody + } + results = append(results, &EditHistoryEntryResult{EditHistoryEntry: entry, PlainBody: plain}) + } + return results, nil +} diff --git a/services/iam/internal/application/chat/list_conversations_handler.go b/services/iam/internal/application/chat/list_conversations_handler.go index 08ae2afb..ac096708 100644 --- a/services/iam/internal/application/chat/list_conversations_handler.go +++ b/services/iam/internal/application/chat/list_conversations_handler.go @@ -5,31 +5,82 @@ import ( "fmt" "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/crypto" ) // ListConversationsHandler lists conversations for the calling user. type ListConversationsHandler struct { convRepo chat.ConversationRepository + msgRepo chat.MessageRepository + enc *crypto.Encryptor } // NewListConversationsHandler constructs the handler. -func NewListConversationsHandler(convRepo chat.ConversationRepository) *ListConversationsHandler { - return &ListConversationsHandler{convRepo: convRepo} +func NewListConversationsHandler(convRepo chat.ConversationRepository, msgRepo chat.MessageRepository, enc *crypto.Encryptor) *ListConversationsHandler { + return &ListConversationsHandler{convRepo: convRepo, msgRepo: msgRepo, enc: enc} +} + +// ConversationSummary pairs a conversation with its decrypted last message +// and the caller's unread count. +type ConversationSummary struct { + Conversation *chat.Conversation + LastMessage *chat.Message + LastMessageBody string + UnreadCount int32 } // ListResult holds the list result with pagination. type ListResult struct { - Conversations []*chat.Conversation + Conversations []*ConversationSummary Total int64 } -// Handle returns paginated conversations for callerID. +// Handle returns paginated conversations for callerID, enriched with last +// message + unread count. Both are batch-loaded (one query each), not N+1. func (h *ListConversationsHandler) Handle(ctx context.Context, callerID uuid.UUID, page, pageSize int) (*ListResult, error) { convs, total, err := h.convRepo.ListByUserID(ctx, callerID, page, pageSize) if err != nil { return nil, fmt.Errorf("list conversations: %w", err) } - return &ListResult{Conversations: convs, Total: total}, nil + + convIDs := make([]uuid.UUID, len(convs)) + for i, c := range convs { + convIDs[i] = c.ID() + } + + lastMsgs, err := h.msgRepo.GetLastMessages(ctx, convIDs) + if err != nil { + return nil, fmt.Errorf("list conversations: last messages: %w", err) + } + unreadCounts, err := h.convRepo.GetUnreadCounts(ctx, convIDs, callerID) + if err != nil { + return nil, fmt.Errorf("list conversations: unread counts: %w", err) + } + + summaries := make([]*ConversationSummary, 0, len(convs)) + for _, c := range convs { + summary := &ConversationSummary{Conversation: c, UnreadCount: unreadCounts[c.ID()]} + if lastMsg := lastMsgs[c.ID()]; lastMsg != nil { + summary.LastMessage = lastMsg + summary.LastMessageBody = h.decryptLastMessage(c, lastMsg) + } + summaries = append(summaries, summary) + } + return &ListResult{Conversations: summaries, Total: total}, nil +} + +func (h *ListConversationsHandler) decryptLastMessage(conv *chat.Conversation, msg *chat.Message) string { + convKeyPlain, err := h.enc.DecryptConversationKey(conv.EncryptionKey()) + if err != nil { + log.Warn().Err(err).Str("conv", conv.ID().String()).Msg("list conversations: decrypt conv key failed") + return "" + } + plain, err := h.enc.DecryptMessage(convKeyPlain, msg.BodyEncrypted()) + if err != nil { + return decryptionErrorBody + } + return plain } diff --git a/services/iam/internal/application/chat/list_messages_handler.go b/services/iam/internal/application/chat/list_messages_handler.go index 425e83a5..6204c9a6 100644 --- a/services/iam/internal/application/chat/list_messages_handler.go +++ b/services/iam/internal/application/chat/list_messages_handler.go @@ -65,7 +65,7 @@ func (h *ListMessagesHandler) Handle(ctx context.Context, callerID, convID uuid. var decErr error plain, decErr = h.enc.DecryptMessage(convKeyPlain, msg.BodyEncrypted()) if decErr != nil { - plain = "[decryption error]" + plain = decryptionErrorBody } } decrypted = append(decrypted, &DecryptedMessage{Message: msg, PlainBody: plain}) diff --git a/services/iam/internal/application/chat/send_message_handler.go b/services/iam/internal/application/chat/send_message_handler.go index e8d15ff5..8a85b83d 100644 --- a/services/iam/internal/application/chat/send_message_handler.go +++ b/services/iam/internal/application/chat/send_message_handler.go @@ -137,7 +137,7 @@ func (h *SendMessageHandler) notifyIfOffline(ctx context.Context, recipientID uu Title: title, Body: body, ActionType: notification.ActionNavigate, - ActionPayload: `{"url":"/chat","label":"Reply"}`, + ActionPayload: `{"path":"/chat"}`, SourceType: "chat_message", SourceID: msg.MessageID().String(), CreatedBy: senderID.String(), diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index a4490cc1..dc43ed50 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -32,6 +32,7 @@ type ChatHandler struct { markRead *appChat.MarkReadHandler setTyping *appChat.SetTypingHandler stream *appChat.StreamHandler + editHistory *appChat.GetEditHistoryHandler userResolver *postgres.ChatUserResolver } @@ -49,6 +50,7 @@ func NewChatHandler( markRead *appChat.MarkReadHandler, setTyping *appChat.SetTypingHandler, stream *appChat.StreamHandler, + editHistory *appChat.GetEditHistoryHandler, userResolver *postgres.ChatUserResolver, ) *ChatHandler { return &ChatHandler{ @@ -56,7 +58,7 @@ func NewChatHandler( getConv: getConv, listConvs: listConvs, leaveConv: leaveConv, sendMsg: sendMsg, editMsg: editMsg, deleteMsg: deleteMsg, listMsgs: listMsgs, markRead: markRead, setTyping: setTyping, - stream: stream, userResolver: userResolver, + stream: stream, editHistory: editHistory, userResolver: userResolver, } } @@ -133,8 +135,8 @@ func (h *ChatHandler) ListConversations(ctx context.Context, req *iamv1.ListConv return nil, mapChatError(err) } protos := make([]*iamv1.ConversationProto, 0, len(result.Conversations)) - for _, c := range result.Conversations { - protos = append(protos, h.convToProto(ctx, c)) + for _, summary := range result.Conversations { + protos = append(protos, h.summaryToProto(ctx, summary)) } totalPages := int32((result.Total + int64(pageSize) - 1) / int64(pageSize)) //nolint:gosec // bounded by page count return &iamv1.ListConversationsResponse{ @@ -142,7 +144,7 @@ func (h *ChatHandler) ListConversations(ctx context.Context, req *iamv1.ListConv Data: protos, Pagination: &commonv1.PaginationResponse{ CurrentPage: int32(page), //nolint:gosec // bounded by request validation - PageSize: int32(pageSize), //nolint:gosec // bounded by request validation + PageSize: int32(pageSize), //nolint:gosec // bounded by request validation TotalItems: result.Total, TotalPages: totalPages, }, @@ -260,6 +262,36 @@ func (h *ChatHandler) ListMessages(ctx context.Context, req *iamv1.ListMessagesR }, nil } +// GetMessageEditHistory returns the edit history for a specific message. +func (h *ChatHandler) GetMessageEditHistory(ctx context.Context, req *iamv1.GetMessageEditHistoryRequest) (*iamv1.GetMessageEditHistoryResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + msgID, err := uuid.Parse(req.GetMessageId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid message_id: %v", err) + } + entries, err := h.editHistory.Handle(ctx, callerID, convID, msgID) + if err != nil { + return nil, mapChatError(err) + } + protos := make([]*iamv1.EditHistoryEntryProto, 0, len(entries)) + for _, e := range entries { + protos = append(protos, &iamv1.EditHistoryEntryProto{ + HistoryId: e.HistoryID(), + Body: e.PlainBody, + EditedBy: e.EditedBy().String(), + EditedAt: e.EditedAt().UTC().Format(time.RFC3339Nano), + }) + } + return &iamv1.GetMessageEditHistoryResponse{Base: chatSuccessBase(), Data: protos}, nil +} + // MarkConversationRead marks all messages as read. func (h *ChatHandler) MarkConversationRead(ctx context.Context, req *iamv1.MarkConversationReadRequest) (*iamv1.MarkConversationReadResponse, error) { callerID, err := getUserIDFromContext(ctx) @@ -316,6 +348,17 @@ func (h *ChatHandler) StreamChatEvents(_ *iamv1.StreamChatEventsRequest, stream } } +// summaryToProto builds a ConversationProto and populates LastMessage + +// UnreadCount from the batch-loaded summary (see ListConversationsHandler). +func (h *ChatHandler) summaryToProto(ctx context.Context, summary *appChat.ConversationSummary) *iamv1.ConversationProto { + proto := h.convToProto(ctx, summary.Conversation) + proto.UnreadCount = summary.UnreadCount + if summary.LastMessage != nil { + proto.LastMessage = msgToProto(summary.LastMessage, summary.LastMessageBody, nil) + } + return proto +} + func (h *ChatHandler) convToProto(ctx context.Context, conv *chat.Conversation) *iamv1.ConversationProto { userIDs := make([]uuid.UUID, 0, len(conv.Participants())) for _, p := range conv.Participants() { diff --git a/services/iam/internal/domain/chat/repository.go b/services/iam/internal/domain/chat/repository.go index dea1bc5a..be72e35d 100644 --- a/services/iam/internal/domain/chat/repository.go +++ b/services/iam/internal/domain/chat/repository.go @@ -32,6 +32,10 @@ type ConversationRepository interface { // UpdateLastReadAt updates chat_participant.last_read_at for a user. UpdateLastReadAt(ctx context.Context, conversationID, userID uuid.UUID, at time.Time) error + + // GetUnreadCounts returns, for each conversation ID, the count of messages + // created after userID's last_read_at (excluding userID's own messages). + GetUnreadCounts(ctx context.Context, conversationIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]int32, error) } // MessageRepository handles persistence for Message aggregates. @@ -56,6 +60,10 @@ type MessageRepository interface { // GetEditHistory returns all edit history for a message, newest first. GetEditHistory(ctx context.Context, messageID uuid.UUID) ([]*EditHistoryEntry, error) + + // GetLastMessages returns, for each conversation ID, its most recent + // non-deleted message (one query, not N+1). + GetLastMessages(ctx context.Context, conversationIDs []uuid.UUID) (map[uuid.UUID]*Message, error) } // ReadReceiptRepository handles read receipts. diff --git a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go index 34e1e3a0..72feb1e8 100644 --- a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go @@ -5,9 +5,12 @@ import ( "database/sql" "errors" "fmt" + "math" + "strings" "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" ) @@ -201,6 +204,59 @@ func (r *ChatConversationRepository) UpdateLastReadAt(ctx context.Context, convI return nil } +// GetUnreadCounts returns, for each conversation ID, the count of messages +// created after userID's last_read_at (excluding userID's own messages). +// Single batched query — avoids N+1 across the conversation list. +func (r *ChatConversationRepository) GetUnreadCounts(ctx context.Context, convIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]int32, error) { + if len(convIDs) == 0 { + return map[uuid.UUID]int32{}, nil + } + placeholders := make([]string, len(convIDs)) + args := make([]any, 0, len(convIDs)+1) + for i, id := range convIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args = append(args, id) + } + userParam := len(convIDs) + 1 + args = append(args, userID) + + q := fmt.Sprintf(` + SELECT cm.conversation_id, COUNT(*) + FROM chat_message cm + JOIN chat_participant cp ON cp.conversation_id = cm.conversation_id AND cp.user_id = $%d + WHERE cm.conversation_id IN (%s) + AND cm.is_deleted = FALSE + AND cm.sender_user_id != $%d + AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) + GROUP BY cm.conversation_id`, userParam, strings.Join(placeholders, ","), userParam) + + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("chat conv repo: get unread counts: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat conv repo: close unread count rows") + } + }() + + counts := make(map[uuid.UUID]int32, len(convIDs)) + for rows.Next() { + var ( + convID uuid.UUID + count int64 + ) + if err := rows.Scan(&convID, &count); err != nil { + return nil, fmt.Errorf("chat conv repo: scan unread count: %w", err) + } + counts[convID] = safeInt64ToInt32Chat(count) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat conv repo: unread count rows err: %w", err) + } + return counts, nil +} + func (r *ChatConversationRepository) loadParticipants(ctx context.Context, convID uuid.UUID) ([]*chat.Participant, error) { const q = ` SELECT user_id, role, joined_at, left_at, last_read_at @@ -215,10 +271,10 @@ func (r *ChatConversationRepository) loadParticipants(ctx context.Context, convI var parts []*chat.Participant for rows.Next() { var ( - userID uuid.UUID - roleStr string - joinedAt time.Time - leftAt, lastReadAt *time.Time + userID uuid.UUID + roleStr string + joinedAt time.Time + leftAt, lastReadAt *time.Time ) if err := rows.Scan(&userID, &roleStr, &joinedAt, &leftAt, &lastReadAt); err != nil { return nil, fmt.Errorf("chat conv repo: scan participant: %w", err) @@ -257,3 +313,11 @@ func (r *ChatConversationRepository) scanConversation(row *sql.Row) (*chat.Conve } return chat.Reconstruct(id, convType, name.String, avatarURL.String, encryptionKey, createdBy, createdAt, updatedAt, deletedAt, nil), nil } + +// safeInt64ToInt32Chat clamps a COUNT(*) result into int32 range. +func safeInt64ToInt32Chat(v int64) int32 { + if v > math.MaxInt32 { + return math.MaxInt32 + } + return int32(v) //nolint:gosec // bounds checked above +} diff --git a/services/iam/internal/infrastructure/postgres/chat_message_repository.go b/services/iam/internal/infrastructure/postgres/chat_message_repository.go index d16490af..a886b8c4 100644 --- a/services/iam/internal/infrastructure/postgres/chat_message_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_message_repository.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" ) @@ -130,6 +131,10 @@ func (r *ChatMessageRepository) ListByConversation(ctx context.Context, convID u msgs = msgs[:pageSize] } + if err := r.attachReceipts(ctx, msgs); err != nil { + return nil, "", false, err + } + nextCursor := "" if hasMore && len(msgs) > 0 { last := msgs[len(msgs)-1] @@ -138,6 +143,59 @@ func (r *ChatMessageRepository) ListByConversation(ctx context.Context, convID u return msgs, nextCursor, hasMore, nil } +// attachReceipts batch-loads read receipts for all given messages (one query, +// not N+1) and reconstructs each message with its receipts attached. +func (r *ChatMessageRepository) attachReceipts(ctx context.Context, msgs []*chat.Message) error { + if len(msgs) == 0 { + return nil + } + placeholders := make([]string, len(msgs)) + args := make([]any, len(msgs)) + for i, msg := range msgs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = msg.MessageID() + } + + q := fmt.Sprintf( + `SELECT message_id, user_id, read_at FROM chat_read_receipt WHERE message_id IN (%s)`, + strings.Join(placeholders, ","), + ) + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return fmt.Errorf("chat msg repo: batch load receipts: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat msg repo: close batch receipt rows") + } + }() + + receiptsByMsg := make(map[uuid.UUID][]*chat.ReadReceipt, len(msgs)) + for rows.Next() { + var ( + mID, uID uuid.UUID + readAt time.Time + ) + if err := rows.Scan(&mID, &uID, &readAt); err != nil { + return fmt.Errorf("chat msg repo: scan batch receipt: %w", err) + } + receiptsByMsg[mID] = append(receiptsByMsg[mID], chat.NewReadReceipt(mID, uID, readAt)) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("chat msg repo: batch receipt rows err: %w", err) + } + + for i, msg := range msgs { + msgs[i] = chat.ReconstructMessage( + msg.MessageID(), msg.ConversationID(), msg.SenderUserID(), + msg.BodyEncrypted(), msg.BodyPlainEncrypted(), + msg.IsEdited(), msg.IsDeleted(), msg.ReplyToID(), + receiptsByMsg[msg.MessageID()], msg.CreatedAt(), msg.UpdatedAt(), + ) + } + return nil +} + // UpdateBody persists body changes after an edit. func (r *ChatMessageRepository) UpdateBody(ctx context.Context, msg *chat.Message) error { const q = ` @@ -190,10 +248,10 @@ func (r *ChatMessageRepository) GetEditHistory(ctx context.Context, messageID uu var entries []*chat.EditHistoryEntry for rows.Next() { var ( - histID int64 - msgID, editedBy uuid.UUID - bodyEnc []byte - editedAt time.Time + histID int64 + msgID, editedBy uuid.UUID + bodyEnc []byte + editedAt time.Time ) if err := rows.Scan(&histID, &msgID, &bodyEnc, &editedBy, &editedAt); err != nil { return nil, fmt.Errorf("chat msg repo: scan edit history: %w", err) @@ -206,6 +264,52 @@ func (r *ChatMessageRepository) GetEditHistory(ctx context.Context, messageID uu return entries, nil } +// GetLastMessages returns, for each conversation ID, its most recent +// non-deleted message. Single query via DISTINCT ON — avoids N+1. +func (r *ChatMessageRepository) GetLastMessages(ctx context.Context, convIDs []uuid.UUID) (map[uuid.UUID]*chat.Message, error) { + if len(convIDs) == 0 { + return map[uuid.UUID]*chat.Message{}, nil + } + placeholders := make([]string, len(convIDs)) + args := make([]any, len(convIDs)) + for i, id := range convIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + q := fmt.Sprintf(` + SELECT DISTINCT ON (conversation_id) + message_id, conversation_id, sender_user_id, + body_encrypted, body_plain_encrypted, + is_edited, is_deleted, reply_to_id, created_at, updated_at + FROM chat_message + WHERE conversation_id IN (%s) AND is_deleted = FALSE + ORDER BY conversation_id, created_at DESC, message_id DESC`, strings.Join(placeholders, ",")) + + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("chat msg repo: get last messages: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat msg repo: close last message rows") + } + }() + + result := make(map[uuid.UUID]*chat.Message, len(convIDs)) + for rows.Next() { + msg, scanErr := r.scanMessageRows(rows) + if scanErr != nil { + return nil, fmt.Errorf("chat msg repo: scan last message: %w", scanErr) + } + result[msg.ConversationID()] = msg + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat msg repo: last message rows err: %w", err) + } + return result, nil +} + func (r *ChatMessageRepository) loadReceipts(ctx context.Context, msgID uuid.UUID) ([]*chat.ReadReceipt, error) { const q = `SELECT message_id, user_id, read_at FROM chat_read_receipt WHERE message_id=$1` rows, err := r.db.QueryContext(ctx, q, msgID) From 5030784825cab4f258e2c8284a0650ae77bf7464 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 11:32:09 +0700 Subject: [PATCH 14/17] fix(iam): populate SenderName in ListMessages response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit msgToProto never set SenderName — messages fetched via ListMessages had empty sender names. SSE-pushed messages had names (via broadcastMessageEvent) but refetch would overwrite them with empty. Batch-resolve sender names via ChatUserResolver for the message page, same pattern as convToProto participant name resolution. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../iam/internal/delivery/grpc/chat_handler.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index dc43ed50..89826606 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -250,9 +250,23 @@ func (h *ChatHandler) ListMessages(ctx context.Context, req *iamv1.ListMessagesR if err != nil { return nil, mapChatError(err) } + senderIDs := make([]uuid.UUID, 0, len(result.Messages)) + for _, dm := range result.Messages { + senderIDs = append(senderIDs, dm.SenderUserID()) + } + senderMap, _ := h.userResolver.ResolveUsers(ctx, senderIDs) + protos := make([]*iamv1.MessageProto, 0, len(result.Messages)) for _, dm := range result.Messages { - protos = append(protos, msgToProto(dm.Message, dm.PlainBody, dm.ReadReceipts())) + mp := msgToProto(dm.Message, dm.PlainBody, dm.ReadReceipts()) + if info := senderMap[dm.SenderUserID()]; info != nil { + if info.FullName != "" { + mp.SenderName = info.FullName + } else { + mp.SenderName = info.Username + } + } + protos = append(protos, mp) } return &iamv1.ListMessagesResponse{ Base: chatSuccessBase(), From 842f0518e64648bec2dec201fc013813f217f655 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 16:22:55 +0700 Subject: [PATCH 15/17] feat(iam): chat clear-history and file/image attachments Implement the backend for the three remaining chat features on top of the regenerated proto bindings. Clear history (per-user): - migration 000077 adds chat_participant.history_cleared_at. - Participant.HistoryClearedAt + ClearHistory repo method; ListByConversation and MarkRead now honor the per-user cleared-history boundary. - ClearHistoryHandler + ClearConversationHistory gRPC method. Attachments: - migration 000078 adds the chat_attachment table (message_id nullable until the message is sent). - domain Attachment aggregate + AttachmentRepository, postgres repository, and MinIO storage.UploadChatAttachment ({basePath}/chat/attachments/{conv}/...). - UploadAttachmentHandler (participant check, nil-storage guard, image thumbnail); SendMessage links pre-uploaded attachments and ListMessages batch-loads them; real-time broadcast carries attachments. Both features are registered as authenticated-only in the permission interceptor and wired in cmd/server/main.go. Authored-By: Ilham R Co-Authored-By: Ilham R Co-Authored-By: Claude Opus 4.8 Co-Authored-By: IT Mutugading Co-Authored-By: Indra Putro Co-Authored-By: Ilham R Co-Authored-By: IT Mutu Gading --- gen/iam/v1/chat.pb.go | 878 ++++++++++++------ gen/iam/v1/chat.pb.gw.go | 162 ++++ gen/iam/v1/chat_grpc.pb.go | 84 ++ gen/openapi/iam/v1/chat.swagger.json | 147 +++ services/iam/cmd/server/main.go | 5 + .../application/chat/broadcast_helpers.go | 15 +- .../application/chat/clear_history_handler.go | 34 + .../application/chat/edit_message_handler.go | 2 +- .../application/chat/list_messages_handler.go | 2 +- .../application/chat/mark_read_handler.go | 6 +- .../application/chat/send_message_handler.go | 63 +- .../chat/upload_attachment_handler.go | 57 ++ .../internal/delivery/grpc/chat_handler.go | 90 +- .../delivery/grpc/permission_interceptor.go | 2 + .../iam/internal/domain/chat/attachment.go | 93 ++ .../internal/domain/chat/attachment_test.go | 88 ++ services/iam/internal/domain/chat/entity.go | 20 +- services/iam/internal/domain/chat/errors.go | 6 + .../iam/internal/domain/chat/repository.go | 24 +- .../postgres/chat_attachment_repository.go | 166 ++++ .../postgres/chat_conversation_repository.go | 23 +- .../postgres/chat_message_repository.go | 34 +- .../infrastructure/storage/storage.go | 32 + .../000077_add_history_cleared_at.down.sql | 2 + .../000077_add_history_cleared_at.up.sql | 2 + .../000078_create_chat_attachment.down.sql | 4 + .../000078_create_chat_attachment.up.sql | 16 + 27 files changed, 1751 insertions(+), 306 deletions(-) create mode 100644 services/iam/internal/application/chat/clear_history_handler.go create mode 100644 services/iam/internal/application/chat/upload_attachment_handler.go create mode 100644 services/iam/internal/domain/chat/attachment.go create mode 100644 services/iam/internal/domain/chat/attachment_test.go create mode 100644 services/iam/internal/infrastructure/postgres/chat_attachment_repository.go create mode 100644 services/iam/migrations/postgres/000077_add_history_cleared_at.down.sql create mode 100644 services/iam/migrations/postgres/000077_add_history_cleared_at.up.sql create mode 100644 services/iam/migrations/postgres/000078_create_chat_attachment.down.sql create mode 100644 services/iam/migrations/postgres/000078_create_chat_attachment.up.sql diff --git a/gen/iam/v1/chat.pb.go b/gen/iam/v1/chat.pb.go index ed640ee3..1aa7f39b 100644 --- a/gen/iam/v1/chat.pb.go +++ b/gen/iam/v1/chat.pb.go @@ -240,6 +240,7 @@ type MessageProto struct { ReadReceipts []*ReadReceiptProto `protobuf:"bytes,9,rep,name=read_receipts,json=readReceipts,proto3" json:"read_receipts,omitempty"` CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` UpdatedAt string `protobuf:"bytes,11,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + Attachments []*AttachmentProto `protobuf:"bytes,12,rep,name=attachments,proto3" json:"attachments,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -351,6 +352,98 @@ func (x *MessageProto) GetUpdatedAt() string { return "" } +func (x *MessageProto) GetAttachments() []*AttachmentProto { + if x != nil { + return x.Attachments + } + return nil +} + +// AttachmentProto represents a file or image attached to a chat message. +type AttachmentProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + AttachmentId string `protobuf:"bytes,1,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"` + FileName string `protobuf:"bytes,2,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"` + FileUrl string `protobuf:"bytes,3,opt,name=file_url,json=fileUrl,proto3" json:"file_url,omitempty"` + ContentType string `protobuf:"bytes,4,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + FileSize int64 `protobuf:"varint,5,opt,name=file_size,json=fileSize,proto3" json:"file_size,omitempty"` + ThumbnailUrl string `protobuf:"bytes,6,opt,name=thumbnail_url,json=thumbnailUrl,proto3" json:"thumbnail_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachmentProto) Reset() { + *x = AttachmentProto{} + mi := &file_iam_v1_chat_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachmentProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachmentProto) ProtoMessage() {} + +func (x *AttachmentProto) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttachmentProto.ProtoReflect.Descriptor instead. +func (*AttachmentProto) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{3} +} + +func (x *AttachmentProto) GetAttachmentId() string { + if x != nil { + return x.AttachmentId + } + return "" +} + +func (x *AttachmentProto) GetFileName() string { + if x != nil { + return x.FileName + } + return "" +} + +func (x *AttachmentProto) GetFileUrl() string { + if x != nil { + return x.FileUrl + } + return "" +} + +func (x *AttachmentProto) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +func (x *AttachmentProto) GetFileSize() int64 { + if x != nil { + return x.FileSize + } + return 0 +} + +func (x *AttachmentProto) GetThumbnailUrl() string { + if x != nil { + return x.ThumbnailUrl + } + return "" +} + // ReadReceiptProto represents a read receipt for a message. type ReadReceiptProto struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -362,7 +455,7 @@ type ReadReceiptProto struct { func (x *ReadReceiptProto) Reset() { *x = ReadReceiptProto{} - mi := &file_iam_v1_chat_proto_msgTypes[3] + mi := &file_iam_v1_chat_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -374,7 +467,7 @@ func (x *ReadReceiptProto) String() string { func (*ReadReceiptProto) ProtoMessage() {} func (x *ReadReceiptProto) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[3] + mi := &file_iam_v1_chat_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -387,7 +480,7 @@ func (x *ReadReceiptProto) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadReceiptProto.ProtoReflect.Descriptor instead. func (*ReadReceiptProto) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{3} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{4} } func (x *ReadReceiptProto) GetUserId() string { @@ -417,7 +510,7 @@ type EditHistoryEntryProto struct { func (x *EditHistoryEntryProto) Reset() { *x = EditHistoryEntryProto{} - mi := &file_iam_v1_chat_proto_msgTypes[4] + mi := &file_iam_v1_chat_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -429,7 +522,7 @@ func (x *EditHistoryEntryProto) String() string { func (*EditHistoryEntryProto) ProtoMessage() {} func (x *EditHistoryEntryProto) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[4] + mi := &file_iam_v1_chat_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -442,7 +535,7 @@ func (x *EditHistoryEntryProto) ProtoReflect() protoreflect.Message { // Deprecated: Use EditHistoryEntryProto.ProtoReflect.Descriptor instead. func (*EditHistoryEntryProto) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{4} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{5} } func (x *EditHistoryEntryProto) GetHistoryId() int64 { @@ -483,7 +576,7 @@ type CreateDirectConversationRequest struct { func (x *CreateDirectConversationRequest) Reset() { *x = CreateDirectConversationRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[5] + mi := &file_iam_v1_chat_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -495,7 +588,7 @@ func (x *CreateDirectConversationRequest) String() string { func (*CreateDirectConversationRequest) ProtoMessage() {} func (x *CreateDirectConversationRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[5] + mi := &file_iam_v1_chat_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -508,7 +601,7 @@ func (x *CreateDirectConversationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateDirectConversationRequest.ProtoReflect.Descriptor instead. func (*CreateDirectConversationRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{5} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{6} } func (x *CreateDirectConversationRequest) GetPeerUserId() string { @@ -529,7 +622,7 @@ type CreateGroupConversationRequest struct { func (x *CreateGroupConversationRequest) Reset() { *x = CreateGroupConversationRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[6] + mi := &file_iam_v1_chat_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -541,7 +634,7 @@ func (x *CreateGroupConversationRequest) String() string { func (*CreateGroupConversationRequest) ProtoMessage() {} func (x *CreateGroupConversationRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[6] + mi := &file_iam_v1_chat_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -554,7 +647,7 @@ func (x *CreateGroupConversationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateGroupConversationRequest.ProtoReflect.Descriptor instead. func (*CreateGroupConversationRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{6} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{7} } func (x *CreateGroupConversationRequest) GetName() string { @@ -581,7 +674,7 @@ type GetConversationRequest struct { func (x *GetConversationRequest) Reset() { *x = GetConversationRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[7] + mi := &file_iam_v1_chat_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -593,7 +686,7 @@ func (x *GetConversationRequest) String() string { func (*GetConversationRequest) ProtoMessage() {} func (x *GetConversationRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[7] + mi := &file_iam_v1_chat_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -606,7 +699,7 @@ func (x *GetConversationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConversationRequest.ProtoReflect.Descriptor instead. func (*GetConversationRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{7} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{8} } func (x *GetConversationRequest) GetConversationId() string { @@ -628,7 +721,7 @@ type ListConversationsRequest struct { func (x *ListConversationsRequest) Reset() { *x = ListConversationsRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[8] + mi := &file_iam_v1_chat_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -640,7 +733,7 @@ func (x *ListConversationsRequest) String() string { func (*ListConversationsRequest) ProtoMessage() {} func (x *ListConversationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[8] + mi := &file_iam_v1_chat_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -653,7 +746,7 @@ func (x *ListConversationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListConversationsRequest.ProtoReflect.Descriptor instead. func (*ListConversationsRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{8} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{9} } func (x *ListConversationsRequest) GetPage() int32 { @@ -689,7 +782,7 @@ type UpdateGroupConversationRequest struct { func (x *UpdateGroupConversationRequest) Reset() { *x = UpdateGroupConversationRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[9] + mi := &file_iam_v1_chat_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -701,7 +794,7 @@ func (x *UpdateGroupConversationRequest) String() string { func (*UpdateGroupConversationRequest) ProtoMessage() {} func (x *UpdateGroupConversationRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[9] + mi := &file_iam_v1_chat_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -714,7 +807,7 @@ func (x *UpdateGroupConversationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateGroupConversationRequest.ProtoReflect.Descriptor instead. func (*UpdateGroupConversationRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{9} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{10} } func (x *UpdateGroupConversationRequest) GetConversationId() string { @@ -749,7 +842,7 @@ type AddParticipantsRequest struct { func (x *AddParticipantsRequest) Reset() { *x = AddParticipantsRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[10] + mi := &file_iam_v1_chat_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -761,7 +854,7 @@ func (x *AddParticipantsRequest) String() string { func (*AddParticipantsRequest) ProtoMessage() {} func (x *AddParticipantsRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[10] + mi := &file_iam_v1_chat_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -774,7 +867,7 @@ func (x *AddParticipantsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddParticipantsRequest.ProtoReflect.Descriptor instead. func (*AddParticipantsRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{10} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{11} } func (x *AddParticipantsRequest) GetConversationId() string { @@ -802,7 +895,7 @@ type RemoveParticipantRequest struct { func (x *RemoveParticipantRequest) Reset() { *x = RemoveParticipantRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[11] + mi := &file_iam_v1_chat_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -814,7 +907,7 @@ func (x *RemoveParticipantRequest) String() string { func (*RemoveParticipantRequest) ProtoMessage() {} func (x *RemoveParticipantRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[11] + mi := &file_iam_v1_chat_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -827,7 +920,7 @@ func (x *RemoveParticipantRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveParticipantRequest.ProtoReflect.Descriptor instead. func (*RemoveParticipantRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{11} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{12} } func (x *RemoveParticipantRequest) GetConversationId() string { @@ -854,7 +947,7 @@ type LeaveConversationRequest struct { func (x *LeaveConversationRequest) Reset() { *x = LeaveConversationRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[12] + mi := &file_iam_v1_chat_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -866,7 +959,7 @@ func (x *LeaveConversationRequest) String() string { func (*LeaveConversationRequest) ProtoMessage() {} func (x *LeaveConversationRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[12] + mi := &file_iam_v1_chat_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -879,7 +972,7 @@ func (x *LeaveConversationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LeaveConversationRequest.ProtoReflect.Descriptor instead. func (*LeaveConversationRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{12} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{13} } func (x *LeaveConversationRequest) GetConversationId() string { @@ -895,14 +988,16 @@ type SendMessageRequest struct { ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` // ID of the message being replied to (optional, empty = no reply). - ReplyToId string `protobuf:"bytes,3,opt,name=reply_to_id,json=replyToId,proto3" json:"reply_to_id,omitempty"` + ReplyToId string `protobuf:"bytes,3,opt,name=reply_to_id,json=replyToId,proto3" json:"reply_to_id,omitempty"` + // Pre-uploaded attachment IDs to associate with this message (optional). + AttachmentIds []string `protobuf:"bytes,4,rep,name=attachment_ids,json=attachmentIds,proto3" json:"attachment_ids,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SendMessageRequest) Reset() { *x = SendMessageRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[13] + mi := &file_iam_v1_chat_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -914,7 +1009,7 @@ func (x *SendMessageRequest) String() string { func (*SendMessageRequest) ProtoMessage() {} func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[13] + mi := &file_iam_v1_chat_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -927,7 +1022,7 @@ func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageRequest.ProtoReflect.Descriptor instead. func (*SendMessageRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{13} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{14} } func (x *SendMessageRequest) GetConversationId() string { @@ -951,6 +1046,13 @@ func (x *SendMessageRequest) GetReplyToId() string { return "" } +func (x *SendMessageRequest) GetAttachmentIds() []string { + if x != nil { + return x.AttachmentIds + } + return nil +} + // EditMessageRequest edits an existing message. type EditMessageRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -963,7 +1065,7 @@ type EditMessageRequest struct { func (x *EditMessageRequest) Reset() { *x = EditMessageRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[14] + mi := &file_iam_v1_chat_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -975,7 +1077,7 @@ func (x *EditMessageRequest) String() string { func (*EditMessageRequest) ProtoMessage() {} func (x *EditMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[14] + mi := &file_iam_v1_chat_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -988,7 +1090,7 @@ func (x *EditMessageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditMessageRequest.ProtoReflect.Descriptor instead. func (*EditMessageRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{14} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{15} } func (x *EditMessageRequest) GetConversationId() string { @@ -1023,7 +1125,7 @@ type DeleteMessageRequest struct { func (x *DeleteMessageRequest) Reset() { *x = DeleteMessageRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[15] + mi := &file_iam_v1_chat_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1035,7 +1137,7 @@ func (x *DeleteMessageRequest) String() string { func (*DeleteMessageRequest) ProtoMessage() {} func (x *DeleteMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[15] + mi := &file_iam_v1_chat_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1048,7 +1150,7 @@ func (x *DeleteMessageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteMessageRequest.ProtoReflect.Descriptor instead. func (*DeleteMessageRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{15} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{16} } func (x *DeleteMessageRequest) GetConversationId() string { @@ -1078,7 +1180,7 @@ type ListMessagesRequest struct { func (x *ListMessagesRequest) Reset() { *x = ListMessagesRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[16] + mi := &file_iam_v1_chat_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1090,7 +1192,7 @@ func (x *ListMessagesRequest) String() string { func (*ListMessagesRequest) ProtoMessage() {} func (x *ListMessagesRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[16] + mi := &file_iam_v1_chat_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1103,7 +1205,7 @@ func (x *ListMessagesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListMessagesRequest.ProtoReflect.Descriptor instead. func (*ListMessagesRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{16} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{17} } func (x *ListMessagesRequest) GetConversationId() string { @@ -1138,7 +1240,7 @@ type StreamChatEventsRequest struct { func (x *StreamChatEventsRequest) Reset() { *x = StreamChatEventsRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[17] + mi := &file_iam_v1_chat_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1150,7 +1252,7 @@ func (x *StreamChatEventsRequest) String() string { func (*StreamChatEventsRequest) ProtoMessage() {} func (x *StreamChatEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[17] + mi := &file_iam_v1_chat_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1163,7 +1265,7 @@ func (x *StreamChatEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamChatEventsRequest.ProtoReflect.Descriptor instead. func (*StreamChatEventsRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{17} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{18} } func (x *StreamChatEventsRequest) GetLastEventId() string { @@ -1183,7 +1285,7 @@ type MarkConversationReadRequest struct { func (x *MarkConversationReadRequest) Reset() { *x = MarkConversationReadRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[18] + mi := &file_iam_v1_chat_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1195,7 +1297,7 @@ func (x *MarkConversationReadRequest) String() string { func (*MarkConversationReadRequest) ProtoMessage() {} func (x *MarkConversationReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[18] + mi := &file_iam_v1_chat_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1208,7 +1310,7 @@ func (x *MarkConversationReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkConversationReadRequest.ProtoReflect.Descriptor instead. func (*MarkConversationReadRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{18} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{19} } func (x *MarkConversationReadRequest) GetConversationId() string { @@ -1218,6 +1320,51 @@ func (x *MarkConversationReadRequest) GetConversationId() string { return "" } +// ClearConversationHistoryRequest clears the calling user's message history for a conversation. +type ClearConversationHistoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearConversationHistoryRequest) Reset() { + *x = ClearConversationHistoryRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearConversationHistoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearConversationHistoryRequest) ProtoMessage() {} + +func (x *ClearConversationHistoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearConversationHistoryRequest.ProtoReflect.Descriptor instead. +func (*ClearConversationHistoryRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{20} +} + +func (x *ClearConversationHistoryRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + // SetTypingRequest sets the typing indicator for the current user. type SetTypingRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1229,7 +1376,7 @@ type SetTypingRequest struct { func (x *SetTypingRequest) Reset() { *x = SetTypingRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[19] + mi := &file_iam_v1_chat_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1241,7 +1388,7 @@ func (x *SetTypingRequest) String() string { func (*SetTypingRequest) ProtoMessage() {} func (x *SetTypingRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[19] + mi := &file_iam_v1_chat_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1254,7 +1401,7 @@ func (x *SetTypingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetTypingRequest.ProtoReflect.Descriptor instead. func (*SetTypingRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{19} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{21} } func (x *SetTypingRequest) GetConversationId() string { @@ -1282,7 +1429,7 @@ type GetMessageEditHistoryRequest struct { func (x *GetMessageEditHistoryRequest) Reset() { *x = GetMessageEditHistoryRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[20] + mi := &file_iam_v1_chat_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1294,7 +1441,7 @@ func (x *GetMessageEditHistoryRequest) String() string { func (*GetMessageEditHistoryRequest) ProtoMessage() {} func (x *GetMessageEditHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[20] + mi := &file_iam_v1_chat_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1307,7 +1454,7 @@ func (x *GetMessageEditHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetMessageEditHistoryRequest.ProtoReflect.Descriptor instead. func (*GetMessageEditHistoryRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{20} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{22} } func (x *GetMessageEditHistoryRequest) GetConversationId() string { @@ -1324,6 +1471,76 @@ func (x *GetMessageEditHistoryRequest) GetMessageId() string { return "" } +// UploadChatAttachmentRequest uploads a single file to a conversation. +type UploadChatAttachmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + FileName string `protobuf:"bytes,2,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"` + ContentType string `protobuf:"bytes,3,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + // Raw file bytes (max 25MB). + FileData []byte `protobuf:"bytes,4,opt,name=file_data,json=fileData,proto3" json:"file_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadChatAttachmentRequest) Reset() { + *x = UploadChatAttachmentRequest{} + mi := &file_iam_v1_chat_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadChatAttachmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadChatAttachmentRequest) ProtoMessage() {} + +func (x *UploadChatAttachmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadChatAttachmentRequest.ProtoReflect.Descriptor instead. +func (*UploadChatAttachmentRequest) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{23} +} + +func (x *UploadChatAttachmentRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *UploadChatAttachmentRequest) GetFileName() string { + if x != nil { + return x.FileName + } + return "" +} + +func (x *UploadChatAttachmentRequest) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +func (x *UploadChatAttachmentRequest) GetFileData() []byte { + if x != nil { + return x.FileData + } + return nil +} + // HeartbeatRequest sends a heartbeat to update online presence. type HeartbeatRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1333,7 +1550,7 @@ type HeartbeatRequest struct { func (x *HeartbeatRequest) Reset() { *x = HeartbeatRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[21] + mi := &file_iam_v1_chat_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1345,7 +1562,7 @@ func (x *HeartbeatRequest) String() string { func (*HeartbeatRequest) ProtoMessage() {} func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[21] + mi := &file_iam_v1_chat_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1358,7 +1575,7 @@ func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HeartbeatRequest.ProtoReflect.Descriptor instead. func (*HeartbeatRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{21} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{24} } // GetOnlineUsersRequest retrieves online user IDs. @@ -1371,7 +1588,7 @@ type GetOnlineUsersRequest struct { func (x *GetOnlineUsersRequest) Reset() { *x = GetOnlineUsersRequest{} - mi := &file_iam_v1_chat_proto_msgTypes[22] + mi := &file_iam_v1_chat_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1383,7 +1600,7 @@ func (x *GetOnlineUsersRequest) String() string { func (*GetOnlineUsersRequest) ProtoMessage() {} func (x *GetOnlineUsersRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[22] + mi := &file_iam_v1_chat_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1396,7 +1613,7 @@ func (x *GetOnlineUsersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetOnlineUsersRequest.ProtoReflect.Descriptor instead. func (*GetOnlineUsersRequest) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{22} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{25} } func (x *GetOnlineUsersRequest) GetUserIds() []string { @@ -1417,7 +1634,7 @@ type CreateDirectConversationResponse struct { func (x *CreateDirectConversationResponse) Reset() { *x = CreateDirectConversationResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[23] + mi := &file_iam_v1_chat_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1429,7 +1646,7 @@ func (x *CreateDirectConversationResponse) String() string { func (*CreateDirectConversationResponse) ProtoMessage() {} func (x *CreateDirectConversationResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[23] + mi := &file_iam_v1_chat_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1442,7 +1659,7 @@ func (x *CreateDirectConversationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateDirectConversationResponse.ProtoReflect.Descriptor instead. func (*CreateDirectConversationResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{23} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{26} } func (x *CreateDirectConversationResponse) GetBase() *v1.BaseResponse { @@ -1470,7 +1687,7 @@ type CreateGroupConversationResponse struct { func (x *CreateGroupConversationResponse) Reset() { *x = CreateGroupConversationResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[24] + mi := &file_iam_v1_chat_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1482,7 +1699,7 @@ func (x *CreateGroupConversationResponse) String() string { func (*CreateGroupConversationResponse) ProtoMessage() {} func (x *CreateGroupConversationResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[24] + mi := &file_iam_v1_chat_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1495,7 +1712,7 @@ func (x *CreateGroupConversationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateGroupConversationResponse.ProtoReflect.Descriptor instead. func (*CreateGroupConversationResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{24} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{27} } func (x *CreateGroupConversationResponse) GetBase() *v1.BaseResponse { @@ -1523,7 +1740,7 @@ type GetConversationResponse struct { func (x *GetConversationResponse) Reset() { *x = GetConversationResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[25] + mi := &file_iam_v1_chat_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1535,7 +1752,7 @@ func (x *GetConversationResponse) String() string { func (*GetConversationResponse) ProtoMessage() {} func (x *GetConversationResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[25] + mi := &file_iam_v1_chat_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1548,7 +1765,7 @@ func (x *GetConversationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConversationResponse.ProtoReflect.Descriptor instead. func (*GetConversationResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{25} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{28} } func (x *GetConversationResponse) GetBase() *v1.BaseResponse { @@ -1577,7 +1794,7 @@ type ListConversationsResponse struct { func (x *ListConversationsResponse) Reset() { *x = ListConversationsResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[26] + mi := &file_iam_v1_chat_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1589,7 +1806,7 @@ func (x *ListConversationsResponse) String() string { func (*ListConversationsResponse) ProtoMessage() {} func (x *ListConversationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[26] + mi := &file_iam_v1_chat_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1602,7 +1819,7 @@ func (x *ListConversationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListConversationsResponse.ProtoReflect.Descriptor instead. func (*ListConversationsResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{26} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{29} } func (x *ListConversationsResponse) GetBase() *v1.BaseResponse { @@ -1637,7 +1854,7 @@ type UpdateGroupConversationResponse struct { func (x *UpdateGroupConversationResponse) Reset() { *x = UpdateGroupConversationResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[27] + mi := &file_iam_v1_chat_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1649,7 +1866,7 @@ func (x *UpdateGroupConversationResponse) String() string { func (*UpdateGroupConversationResponse) ProtoMessage() {} func (x *UpdateGroupConversationResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[27] + mi := &file_iam_v1_chat_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1662,7 +1879,7 @@ func (x *UpdateGroupConversationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateGroupConversationResponse.ProtoReflect.Descriptor instead. func (*UpdateGroupConversationResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{27} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{30} } func (x *UpdateGroupConversationResponse) GetBase() *v1.BaseResponse { @@ -1689,7 +1906,7 @@ type AddParticipantsResponse struct { func (x *AddParticipantsResponse) Reset() { *x = AddParticipantsResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[28] + mi := &file_iam_v1_chat_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1701,7 +1918,7 @@ func (x *AddParticipantsResponse) String() string { func (*AddParticipantsResponse) ProtoMessage() {} func (x *AddParticipantsResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[28] + mi := &file_iam_v1_chat_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1714,7 +1931,7 @@ func (x *AddParticipantsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddParticipantsResponse.ProtoReflect.Descriptor instead. func (*AddParticipantsResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{28} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{31} } func (x *AddParticipantsResponse) GetBase() *v1.BaseResponse { @@ -1734,7 +1951,7 @@ type RemoveParticipantResponse struct { func (x *RemoveParticipantResponse) Reset() { *x = RemoveParticipantResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[29] + mi := &file_iam_v1_chat_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1746,7 +1963,7 @@ func (x *RemoveParticipantResponse) String() string { func (*RemoveParticipantResponse) ProtoMessage() {} func (x *RemoveParticipantResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[29] + mi := &file_iam_v1_chat_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1759,7 +1976,7 @@ func (x *RemoveParticipantResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveParticipantResponse.ProtoReflect.Descriptor instead. func (*RemoveParticipantResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{29} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{32} } func (x *RemoveParticipantResponse) GetBase() *v1.BaseResponse { @@ -1779,7 +1996,7 @@ type LeaveConversationResponse struct { func (x *LeaveConversationResponse) Reset() { *x = LeaveConversationResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[30] + mi := &file_iam_v1_chat_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1791,7 +2008,7 @@ func (x *LeaveConversationResponse) String() string { func (*LeaveConversationResponse) ProtoMessage() {} func (x *LeaveConversationResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[30] + mi := &file_iam_v1_chat_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1804,7 +2021,7 @@ func (x *LeaveConversationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LeaveConversationResponse.ProtoReflect.Descriptor instead. func (*LeaveConversationResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{30} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{33} } func (x *LeaveConversationResponse) GetBase() *v1.BaseResponse { @@ -1825,7 +2042,7 @@ type SendMessageResponse struct { func (x *SendMessageResponse) Reset() { *x = SendMessageResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[31] + mi := &file_iam_v1_chat_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1837,7 +2054,7 @@ func (x *SendMessageResponse) String() string { func (*SendMessageResponse) ProtoMessage() {} func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[31] + mi := &file_iam_v1_chat_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1850,7 +2067,7 @@ func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageResponse.ProtoReflect.Descriptor instead. func (*SendMessageResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{31} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{34} } func (x *SendMessageResponse) GetBase() *v1.BaseResponse { @@ -1878,7 +2095,7 @@ type EditMessageResponse struct { func (x *EditMessageResponse) Reset() { *x = EditMessageResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[32] + mi := &file_iam_v1_chat_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1890,7 +2107,7 @@ func (x *EditMessageResponse) String() string { func (*EditMessageResponse) ProtoMessage() {} func (x *EditMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[32] + mi := &file_iam_v1_chat_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1903,7 +2120,7 @@ func (x *EditMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditMessageResponse.ProtoReflect.Descriptor instead. func (*EditMessageResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{32} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{35} } func (x *EditMessageResponse) GetBase() *v1.BaseResponse { @@ -1930,7 +2147,7 @@ type DeleteMessageResponse struct { func (x *DeleteMessageResponse) Reset() { *x = DeleteMessageResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[33] + mi := &file_iam_v1_chat_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1942,7 +2159,7 @@ func (x *DeleteMessageResponse) String() string { func (*DeleteMessageResponse) ProtoMessage() {} func (x *DeleteMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[33] + mi := &file_iam_v1_chat_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1955,7 +2172,7 @@ func (x *DeleteMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteMessageResponse.ProtoReflect.Descriptor instead. func (*DeleteMessageResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{33} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{36} } func (x *DeleteMessageResponse) GetBase() *v1.BaseResponse { @@ -1978,7 +2195,7 @@ type ListMessagesResponse struct { func (x *ListMessagesResponse) Reset() { *x = ListMessagesResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[34] + mi := &file_iam_v1_chat_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1990,7 +2207,7 @@ func (x *ListMessagesResponse) String() string { func (*ListMessagesResponse) ProtoMessage() {} func (x *ListMessagesResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[34] + mi := &file_iam_v1_chat_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2003,7 +2220,7 @@ func (x *ListMessagesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListMessagesResponse.ProtoReflect.Descriptor instead. func (*ListMessagesResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{34} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{37} } func (x *ListMessagesResponse) GetBase() *v1.BaseResponse { @@ -2044,7 +2261,7 @@ type MarkConversationReadResponse struct { func (x *MarkConversationReadResponse) Reset() { *x = MarkConversationReadResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[35] + mi := &file_iam_v1_chat_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2056,7 +2273,7 @@ func (x *MarkConversationReadResponse) String() string { func (*MarkConversationReadResponse) ProtoMessage() {} func (x *MarkConversationReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[35] + mi := &file_iam_v1_chat_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2069,7 +2286,7 @@ func (x *MarkConversationReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkConversationReadResponse.ProtoReflect.Descriptor instead. func (*MarkConversationReadResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{35} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{38} } func (x *MarkConversationReadResponse) GetBase() *v1.BaseResponse { @@ -2079,6 +2296,51 @@ func (x *MarkConversationReadResponse) GetBase() *v1.BaseResponse { return nil } +// ClearConversationHistoryResponse confirms the caller's history was cleared. +type ClearConversationHistoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearConversationHistoryResponse) Reset() { + *x = ClearConversationHistoryResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearConversationHistoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearConversationHistoryResponse) ProtoMessage() {} + +func (x *ClearConversationHistoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearConversationHistoryResponse.ProtoReflect.Descriptor instead. +func (*ClearConversationHistoryResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{39} +} + +func (x *ClearConversationHistoryResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + // SetTypingResponse confirms the typing indicator was set. type SetTypingResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2089,7 +2351,7 @@ type SetTypingResponse struct { func (x *SetTypingResponse) Reset() { *x = SetTypingResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[36] + mi := &file_iam_v1_chat_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2101,7 +2363,7 @@ func (x *SetTypingResponse) String() string { func (*SetTypingResponse) ProtoMessage() {} func (x *SetTypingResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[36] + mi := &file_iam_v1_chat_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2114,7 +2376,7 @@ func (x *SetTypingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetTypingResponse.ProtoReflect.Descriptor instead. func (*SetTypingResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{36} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{40} } func (x *SetTypingResponse) GetBase() *v1.BaseResponse { @@ -2135,7 +2397,7 @@ type GetMessageEditHistoryResponse struct { func (x *GetMessageEditHistoryResponse) Reset() { *x = GetMessageEditHistoryResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[37] + mi := &file_iam_v1_chat_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2147,7 +2409,7 @@ func (x *GetMessageEditHistoryResponse) String() string { func (*GetMessageEditHistoryResponse) ProtoMessage() {} func (x *GetMessageEditHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[37] + mi := &file_iam_v1_chat_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2160,7 +2422,7 @@ func (x *GetMessageEditHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetMessageEditHistoryResponse.ProtoReflect.Descriptor instead. func (*GetMessageEditHistoryResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{37} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{41} } func (x *GetMessageEditHistoryResponse) GetBase() *v1.BaseResponse { @@ -2177,6 +2439,59 @@ func (x *GetMessageEditHistoryResponse) GetData() []*EditHistoryEntryProto { return nil } +// UploadChatAttachmentResponse returns the uploaded attachment metadata. +type UploadChatAttachmentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *v1.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Data *AttachmentProto `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadChatAttachmentResponse) Reset() { + *x = UploadChatAttachmentResponse{} + mi := &file_iam_v1_chat_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadChatAttachmentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadChatAttachmentResponse) ProtoMessage() {} + +func (x *UploadChatAttachmentResponse) ProtoReflect() protoreflect.Message { + mi := &file_iam_v1_chat_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadChatAttachmentResponse.ProtoReflect.Descriptor instead. +func (*UploadChatAttachmentResponse) Descriptor() ([]byte, []int) { + return file_iam_v1_chat_proto_rawDescGZIP(), []int{42} +} + +func (x *UploadChatAttachmentResponse) GetBase() *v1.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *UploadChatAttachmentResponse) GetData() *AttachmentProto { + if x != nil { + return x.Data + } + return nil +} + // HeartbeatResponse confirms the heartbeat was received. type HeartbeatResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2187,7 +2502,7 @@ type HeartbeatResponse struct { func (x *HeartbeatResponse) Reset() { *x = HeartbeatResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[38] + mi := &file_iam_v1_chat_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2199,7 +2514,7 @@ func (x *HeartbeatResponse) String() string { func (*HeartbeatResponse) ProtoMessage() {} func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[38] + mi := &file_iam_v1_chat_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2212,7 +2527,7 @@ func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HeartbeatResponse.ProtoReflect.Descriptor instead. func (*HeartbeatResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{38} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{43} } func (x *HeartbeatResponse) GetBase() *v1.BaseResponse { @@ -2233,7 +2548,7 @@ type GetOnlineUsersResponse struct { func (x *GetOnlineUsersResponse) Reset() { *x = GetOnlineUsersResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[39] + mi := &file_iam_v1_chat_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2245,7 +2560,7 @@ func (x *GetOnlineUsersResponse) String() string { func (*GetOnlineUsersResponse) ProtoMessage() {} func (x *GetOnlineUsersResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[39] + mi := &file_iam_v1_chat_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2258,7 +2573,7 @@ func (x *GetOnlineUsersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetOnlineUsersResponse.ProtoReflect.Descriptor instead. func (*GetOnlineUsersResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{39} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{44} } func (x *GetOnlineUsersResponse) GetBase() *v1.BaseResponse { @@ -2294,7 +2609,7 @@ type StreamChatEventsResponse struct { func (x *StreamChatEventsResponse) Reset() { *x = StreamChatEventsResponse{} - mi := &file_iam_v1_chat_proto_msgTypes[40] + mi := &file_iam_v1_chat_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2306,7 +2621,7 @@ func (x *StreamChatEventsResponse) String() string { func (*StreamChatEventsResponse) ProtoMessage() {} func (x *StreamChatEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[40] + mi := &file_iam_v1_chat_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2319,7 +2634,7 @@ func (x *StreamChatEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamChatEventsResponse.ProtoReflect.Descriptor instead. func (*StreamChatEventsResponse) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{40} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{45} } func (x *StreamChatEventsResponse) GetEventId() string { @@ -2441,7 +2756,7 @@ type MessageEvent struct { func (x *MessageEvent) Reset() { *x = MessageEvent{} - mi := &file_iam_v1_chat_proto_msgTypes[41] + mi := &file_iam_v1_chat_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2453,7 +2768,7 @@ func (x *MessageEvent) String() string { func (*MessageEvent) ProtoMessage() {} func (x *MessageEvent) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[41] + mi := &file_iam_v1_chat_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2466,7 +2781,7 @@ func (x *MessageEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use MessageEvent.ProtoReflect.Descriptor instead. func (*MessageEvent) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{41} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{46} } func (x *MessageEvent) GetConversationId() string { @@ -2494,7 +2809,7 @@ type DeleteEvent struct { func (x *DeleteEvent) Reset() { *x = DeleteEvent{} - mi := &file_iam_v1_chat_proto_msgTypes[42] + mi := &file_iam_v1_chat_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2506,7 +2821,7 @@ func (x *DeleteEvent) String() string { func (*DeleteEvent) ProtoMessage() {} func (x *DeleteEvent) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[42] + mi := &file_iam_v1_chat_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2519,7 +2834,7 @@ func (x *DeleteEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteEvent.ProtoReflect.Descriptor instead. func (*DeleteEvent) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{42} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{47} } func (x *DeleteEvent) GetConversationId() string { @@ -2549,7 +2864,7 @@ type TypingEvent struct { func (x *TypingEvent) Reset() { *x = TypingEvent{} - mi := &file_iam_v1_chat_proto_msgTypes[43] + mi := &file_iam_v1_chat_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2561,7 +2876,7 @@ func (x *TypingEvent) String() string { func (*TypingEvent) ProtoMessage() {} func (x *TypingEvent) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[43] + mi := &file_iam_v1_chat_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2574,7 +2889,7 @@ func (x *TypingEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use TypingEvent.ProtoReflect.Descriptor instead. func (*TypingEvent) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{43} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{48} } func (x *TypingEvent) GetConversationId() string { @@ -2618,7 +2933,7 @@ type ReadEvent struct { func (x *ReadEvent) Reset() { *x = ReadEvent{} - mi := &file_iam_v1_chat_proto_msgTypes[44] + mi := &file_iam_v1_chat_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2630,7 +2945,7 @@ func (x *ReadEvent) String() string { func (*ReadEvent) ProtoMessage() {} func (x *ReadEvent) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[44] + mi := &file_iam_v1_chat_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2643,7 +2958,7 @@ func (x *ReadEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadEvent.ProtoReflect.Descriptor instead. func (*ReadEvent) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{44} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{49} } func (x *ReadEvent) GetConversationId() string { @@ -2685,7 +3000,7 @@ type PresenceEvent struct { func (x *PresenceEvent) Reset() { *x = PresenceEvent{} - mi := &file_iam_v1_chat_proto_msgTypes[45] + mi := &file_iam_v1_chat_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2697,7 +3012,7 @@ func (x *PresenceEvent) String() string { func (*PresenceEvent) ProtoMessage() {} func (x *PresenceEvent) ProtoReflect() protoreflect.Message { - mi := &file_iam_v1_chat_proto_msgTypes[45] + mi := &file_iam_v1_chat_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2710,7 +3025,7 @@ func (x *PresenceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use PresenceEvent.ProtoReflect.Descriptor instead. func (*PresenceEvent) Descriptor() ([]byte, []int) { - return file_iam_v1_chat_proto_rawDescGZIP(), []int{45} + return file_iam_v1_chat_proto_rawDescGZIP(), []int{50} } func (x *PresenceEvent) GetUserId() string { @@ -2753,7 +3068,7 @@ const file_iam_v1_chat_proto_rawDesc = "" + "avatar_url\x18\x04 \x01(\tR\tavatarUrl\x12\x12\n" + "\x04role\x18\x05 \x01(\tR\x04role\x12\x1b\n" + "\tis_online\x18\x06 \x01(\bR\bisOnline\x12\x1b\n" + - "\tjoined_at\x18\a \x01(\tR\bjoinedAt\"\x8a\x03\n" + + "\tjoined_at\x18\a \x01(\tR\bjoinedAt\"\xc5\x03\n" + "\fMessageProto\x12\x1d\n" + "\n" + "message_id\x18\x01 \x01(\tR\tmessageId\x12'\n" + @@ -2771,7 +3086,15 @@ const file_iam_v1_chat_proto_rawDesc = "" + "created_at\x18\n" + " \x01(\tR\tcreatedAt\x12\x1d\n" + "\n" + - "updated_at\x18\v \x01(\tR\tupdatedAt\"D\n" + + "updated_at\x18\v \x01(\tR\tupdatedAt\x129\n" + + "\vattachments\x18\f \x03(\v2\x17.iam.v1.AttachmentProtoR\vattachments\"\xd3\x01\n" + + "\x0fAttachmentProto\x12#\n" + + "\rattachment_id\x18\x01 \x01(\tR\fattachmentId\x12\x1b\n" + + "\tfile_name\x18\x02 \x01(\tR\bfileName\x12\x19\n" + + "\bfile_url\x18\x03 \x01(\tR\afileUrl\x12!\n" + + "\fcontent_type\x18\x04 \x01(\tR\vcontentType\x12\x1b\n" + + "\tfile_size\x18\x05 \x01(\x03R\bfileSize\x12#\n" + + "\rthumbnail_url\x18\x06 \x01(\tR\fthumbnailUrl\"D\n" + "\x10ReadReceiptProto\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x17\n" + "\aread_at\x18\x02 \x01(\tR\x06readAt\"\x84\x01\n" + @@ -2806,12 +3129,13 @@ const file_iam_v1_chat_proto_rawDesc = "" + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12!\n" + "\auser_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\"M\n" + "\x18LeaveConversationRequest\x121\n" + - "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\"\x87\x01\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\"\xae\x01\n" + "\x12SendMessageRequest\x121\n" + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12\x1e\n" + "\x04body\x18\x02 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x90NR\x04body\x12\x1e\n" + - "\vreply_to_id\x18\x03 \x01(\tR\treplyToId\"\x90\x01\n" + + "\vreply_to_id\x18\x03 \x01(\tR\treplyToId\x12%\n" + + "\x0eattachment_ids\x18\x04 \x03(\tR\rattachmentIds\"\x90\x01\n" + "\x12EditMessageRequest\x121\n" + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12'\n" + "\n" + @@ -2829,6 +3153,8 @@ const file_iam_v1_chat_proto_rawDesc = "" + "\x17StreamChatEventsRequest\x12\"\n" + "\rlast_event_id\x18\x01 \x01(\tR\vlastEventId\"P\n" + "\x1bMarkConversationReadRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\"T\n" + + "\x1fClearConversationHistoryRequest\x121\n" + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\"b\n" + "\x10SetTypingRequest\x121\n" + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12\x1b\n" + @@ -2836,7 +3162,13 @@ const file_iam_v1_chat_proto_rawDesc = "" + "\x1cGetMessageEditHistoryRequest\x121\n" + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12'\n" + "\n" + - "message_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tmessageId\"\x12\n" + + "message_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tmessageId\"\xd2\x01\n" + + "\x1bUploadChatAttachmentRequest\x121\n" + + "\x0fconversation_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0econversationId\x12'\n" + + "\tfile_name\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\bfileName\x12,\n" + + "\fcontent_type\x18\x03 \x01(\tB\t\xbaH\x06r\x04\x10\x01\x18dR\vcontentType\x12)\n" + + "\tfile_data\x18\x04 \x01(\fB\f\xbaH\tz\a\x10\x01\x18\x80\x80\xc0\fR\bfileData\"\x12\n" + "\x10HeartbeatRequest\"2\n" + "\x15GetOnlineUsersRequest\x12\x19\n" + "\buser_ids\x18\x01 \x03(\tR\auserIds\"~\n" + @@ -2879,12 +3211,17 @@ const file_iam_v1_chat_proto_rawDesc = "" + "nextCursor\x12\x19\n" + "\bhas_more\x18\x04 \x01(\bR\ahasMore\"K\n" + "\x1cMarkConversationReadResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"O\n" + + " ClearConversationHistoryResponse\x12+\n" + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"@\n" + "\x11SetTypingResponse\x12+\n" + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"\x7f\n" + "\x1dGetMessageEditHistoryResponse\x12+\n" + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x121\n" + - "\x04data\x18\x02 \x03(\v2\x1d.iam.v1.EditHistoryEntryProtoR\x04data\"@\n" + + "\x04data\x18\x02 \x03(\v2\x1d.iam.v1.EditHistoryEntryProtoR\x04data\"x\n" + + "\x1cUploadChatAttachmentResponse\x12+\n" + + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\x12+\n" + + "\x04data\x18\x02 \x01(\v2\x17.iam.v1.AttachmentProtoR\x04data\"@\n" + "\x11HeartbeatResponse\x12+\n" + "\x04base\x18\x01 \x01(\v2\x17.common.v1.BaseResponseR\x04base\"`\n" + "\x16GetOnlineUsersResponse\x12+\n" + @@ -2919,7 +3256,7 @@ const file_iam_v1_chat_proto_rawDesc = "" + "\aread_at\x18\x04 \x01(\tR\x06readAt\"E\n" + "\rPresenceEvent\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x1b\n" + - "\tis_online\x18\x02 \x01(\bR\bisOnline2\xb1\x13\n" + + "\tis_online\x18\x02 \x01(\bR\bisOnline2\x90\x16\n" + "\vChatService\x12\x9f\x01\n" + "\x18CreateDirectConversation\x12'.iam.v1.CreateDirectConversationRequest\x1a(.iam.v1.CreateDirectConversationResponse\"0\x82\xd3\xe4\x93\x02*:\x01*\"%/api/v1/iam/chat/conversations/direct\x12\x9b\x01\n" + "\x17CreateGroupConversation\x12&.iam.v1.CreateGroupConversationRequest\x1a'.iam.v1.CreateGroupConversationResponse\"/\x82\xd3\xe4\x93\x02):\x01*\"$/api/v1/iam/chat/conversations/group\x12\x8c\x01\n" + @@ -2934,9 +3271,11 @@ const file_iam_v1_chat_proto_rawDesc = "" + "\rDeleteMessage\x12\x1c.iam.v1.DeleteMessageRequest\x1a\x1d.iam.v1.DeleteMessageResponse\"N\x82\xd3\xe4\x93\x02H*F/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}\x12\x8c\x01\n" + "\fListMessages\x12\x1b.iam.v1.ListMessagesRequest\x1a\x1c.iam.v1.ListMessagesResponse\"A\x82\xd3\xe4\x93\x02;\x129/api/v1/iam/chat/conversations/{conversation_id}/messages\x12x\n" + "\x10StreamChatEvents\x12\x1f.iam.v1.StreamChatEventsRequest\x1a .iam.v1.StreamChatEventsResponse\"\x1f\x82\xd3\xe4\x93\x02\x19\x12\x17/api/v1/iam/chat/events0\x01\x12\xa3\x01\n" + - "\x14MarkConversationRead\x12#.iam.v1.MarkConversationReadRequest\x1a$.iam.v1.MarkConversationReadResponse\"@\x82\xd3\xe4\x93\x02::\x01*\"5/api/v1/iam/chat/conversations/{conversation_id}/read\x12\x84\x01\n" + + "\x14MarkConversationRead\x12#.iam.v1.MarkConversationReadRequest\x1a$.iam.v1.MarkConversationReadResponse\"@\x82\xd3\xe4\x93\x02::\x01*\"5/api/v1/iam/chat/conversations/{conversation_id}/read\x12\xaf\x01\n" + + "\x18ClearConversationHistory\x12'.iam.v1.ClearConversationHistoryRequest\x1a(.iam.v1.ClearConversationHistoryResponse\"@\x82\xd3\xe4\x93\x02:*8/api/v1/iam/chat/conversations/{conversation_id}/history\x12\x84\x01\n" + "\tSetTyping\x12\x18.iam.v1.SetTypingRequest\x1a\x19.iam.v1.SetTypingResponse\"B\x82\xd3\xe4\x93\x02<:\x01*\"7/api/v1/iam/chat/conversations/{conversation_id}/typing\x12\xbc\x01\n" + - "\x15GetMessageEditHistory\x12$.iam.v1.GetMessageEditHistoryRequest\x1a%.iam.v1.GetMessageEditHistoryResponse\"V\x82\xd3\xe4\x93\x02P\x12N/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}/history2\xf4\x01\n" + + "\x15GetMessageEditHistory\x12$.iam.v1.GetMessageEditHistoryRequest\x1a%.iam.v1.GetMessageEditHistoryResponse\"V\x82\xd3\xe4\x93\x02P\x12N/api/v1/iam/chat/conversations/{conversation_id}/messages/{message_id}/history\x12\xaa\x01\n" + + "\x14UploadChatAttachment\x12#.iam.v1.UploadChatAttachmentRequest\x1a$.iam.v1.UploadChatAttachmentResponse\"G\x82\xd3\xe4\x93\x02A:\x01*\" iam.v1.ParticipantProto 2, // 1: iam.v1.ConversationProto.last_message:type_name -> iam.v1.MessageProto - 3, // 2: iam.v1.MessageProto.read_receipts:type_name -> iam.v1.ReadReceiptProto - 46, // 3: iam.v1.CreateDirectConversationResponse.base:type_name -> common.v1.BaseResponse - 0, // 4: iam.v1.CreateDirectConversationResponse.data:type_name -> iam.v1.ConversationProto - 46, // 5: iam.v1.CreateGroupConversationResponse.base:type_name -> common.v1.BaseResponse - 0, // 6: iam.v1.CreateGroupConversationResponse.data:type_name -> iam.v1.ConversationProto - 46, // 7: iam.v1.GetConversationResponse.base:type_name -> common.v1.BaseResponse - 0, // 8: iam.v1.GetConversationResponse.data:type_name -> iam.v1.ConversationProto - 46, // 9: iam.v1.ListConversationsResponse.base:type_name -> common.v1.BaseResponse - 0, // 10: iam.v1.ListConversationsResponse.data:type_name -> iam.v1.ConversationProto - 47, // 11: iam.v1.ListConversationsResponse.pagination:type_name -> common.v1.PaginationResponse - 46, // 12: iam.v1.UpdateGroupConversationResponse.base:type_name -> common.v1.BaseResponse - 0, // 13: iam.v1.UpdateGroupConversationResponse.data:type_name -> iam.v1.ConversationProto - 46, // 14: iam.v1.AddParticipantsResponse.base:type_name -> common.v1.BaseResponse - 46, // 15: iam.v1.RemoveParticipantResponse.base:type_name -> common.v1.BaseResponse - 46, // 16: iam.v1.LeaveConversationResponse.base:type_name -> common.v1.BaseResponse - 46, // 17: iam.v1.SendMessageResponse.base:type_name -> common.v1.BaseResponse - 2, // 18: iam.v1.SendMessageResponse.data:type_name -> iam.v1.MessageProto - 46, // 19: iam.v1.EditMessageResponse.base:type_name -> common.v1.BaseResponse - 2, // 20: iam.v1.EditMessageResponse.data:type_name -> iam.v1.MessageProto - 46, // 21: iam.v1.DeleteMessageResponse.base:type_name -> common.v1.BaseResponse - 46, // 22: iam.v1.ListMessagesResponse.base:type_name -> common.v1.BaseResponse - 2, // 23: iam.v1.ListMessagesResponse.data:type_name -> iam.v1.MessageProto - 46, // 24: iam.v1.MarkConversationReadResponse.base:type_name -> common.v1.BaseResponse - 46, // 25: iam.v1.SetTypingResponse.base:type_name -> common.v1.BaseResponse - 46, // 26: iam.v1.GetMessageEditHistoryResponse.base:type_name -> common.v1.BaseResponse - 4, // 27: iam.v1.GetMessageEditHistoryResponse.data:type_name -> iam.v1.EditHistoryEntryProto - 46, // 28: iam.v1.HeartbeatResponse.base:type_name -> common.v1.BaseResponse - 46, // 29: iam.v1.GetOnlineUsersResponse.base:type_name -> common.v1.BaseResponse - 41, // 30: iam.v1.StreamChatEventsResponse.message_received:type_name -> iam.v1.MessageEvent - 41, // 31: iam.v1.StreamChatEventsResponse.message_edited:type_name -> iam.v1.MessageEvent - 42, // 32: iam.v1.StreamChatEventsResponse.message_deleted:type_name -> iam.v1.DeleteEvent - 43, // 33: iam.v1.StreamChatEventsResponse.typing:type_name -> iam.v1.TypingEvent - 44, // 34: iam.v1.StreamChatEventsResponse.read_receipt:type_name -> iam.v1.ReadEvent - 45, // 35: iam.v1.StreamChatEventsResponse.presence:type_name -> iam.v1.PresenceEvent - 2, // 36: iam.v1.MessageEvent.message:type_name -> iam.v1.MessageProto - 5, // 37: iam.v1.ChatService.CreateDirectConversation:input_type -> iam.v1.CreateDirectConversationRequest - 6, // 38: iam.v1.ChatService.CreateGroupConversation:input_type -> iam.v1.CreateGroupConversationRequest - 7, // 39: iam.v1.ChatService.GetConversation:input_type -> iam.v1.GetConversationRequest - 8, // 40: iam.v1.ChatService.ListConversations:input_type -> iam.v1.ListConversationsRequest - 9, // 41: iam.v1.ChatService.UpdateGroupConversation:input_type -> iam.v1.UpdateGroupConversationRequest - 10, // 42: iam.v1.ChatService.AddParticipants:input_type -> iam.v1.AddParticipantsRequest - 11, // 43: iam.v1.ChatService.RemoveParticipant:input_type -> iam.v1.RemoveParticipantRequest - 12, // 44: iam.v1.ChatService.LeaveConversation:input_type -> iam.v1.LeaveConversationRequest - 13, // 45: iam.v1.ChatService.SendMessage:input_type -> iam.v1.SendMessageRequest - 14, // 46: iam.v1.ChatService.EditMessage:input_type -> iam.v1.EditMessageRequest - 15, // 47: iam.v1.ChatService.DeleteMessage:input_type -> iam.v1.DeleteMessageRequest - 16, // 48: iam.v1.ChatService.ListMessages:input_type -> iam.v1.ListMessagesRequest - 17, // 49: iam.v1.ChatService.StreamChatEvents:input_type -> iam.v1.StreamChatEventsRequest - 18, // 50: iam.v1.ChatService.MarkConversationRead:input_type -> iam.v1.MarkConversationReadRequest - 19, // 51: iam.v1.ChatService.SetTyping:input_type -> iam.v1.SetTypingRequest - 20, // 52: iam.v1.ChatService.GetMessageEditHistory:input_type -> iam.v1.GetMessageEditHistoryRequest - 21, // 53: iam.v1.PresenceService.Heartbeat:input_type -> iam.v1.HeartbeatRequest - 22, // 54: iam.v1.PresenceService.GetOnlineUsers:input_type -> iam.v1.GetOnlineUsersRequest - 23, // 55: iam.v1.ChatService.CreateDirectConversation:output_type -> iam.v1.CreateDirectConversationResponse - 24, // 56: iam.v1.ChatService.CreateGroupConversation:output_type -> iam.v1.CreateGroupConversationResponse - 25, // 57: iam.v1.ChatService.GetConversation:output_type -> iam.v1.GetConversationResponse - 26, // 58: iam.v1.ChatService.ListConversations:output_type -> iam.v1.ListConversationsResponse - 27, // 59: iam.v1.ChatService.UpdateGroupConversation:output_type -> iam.v1.UpdateGroupConversationResponse - 28, // 60: iam.v1.ChatService.AddParticipants:output_type -> iam.v1.AddParticipantsResponse - 29, // 61: iam.v1.ChatService.RemoveParticipant:output_type -> iam.v1.RemoveParticipantResponse - 30, // 62: iam.v1.ChatService.LeaveConversation:output_type -> iam.v1.LeaveConversationResponse - 31, // 63: iam.v1.ChatService.SendMessage:output_type -> iam.v1.SendMessageResponse - 32, // 64: iam.v1.ChatService.EditMessage:output_type -> iam.v1.EditMessageResponse - 33, // 65: iam.v1.ChatService.DeleteMessage:output_type -> iam.v1.DeleteMessageResponse - 34, // 66: iam.v1.ChatService.ListMessages:output_type -> iam.v1.ListMessagesResponse - 40, // 67: iam.v1.ChatService.StreamChatEvents:output_type -> iam.v1.StreamChatEventsResponse - 35, // 68: iam.v1.ChatService.MarkConversationRead:output_type -> iam.v1.MarkConversationReadResponse - 36, // 69: iam.v1.ChatService.SetTyping:output_type -> iam.v1.SetTypingResponse - 37, // 70: iam.v1.ChatService.GetMessageEditHistory:output_type -> iam.v1.GetMessageEditHistoryResponse - 38, // 71: iam.v1.PresenceService.Heartbeat:output_type -> iam.v1.HeartbeatResponse - 39, // 72: iam.v1.PresenceService.GetOnlineUsers:output_type -> iam.v1.GetOnlineUsersResponse - 55, // [55:73] is the sub-list for method output_type - 37, // [37:55] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 4, // 2: iam.v1.MessageProto.read_receipts:type_name -> iam.v1.ReadReceiptProto + 3, // 3: iam.v1.MessageProto.attachments:type_name -> iam.v1.AttachmentProto + 51, // 4: iam.v1.CreateDirectConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 5: iam.v1.CreateDirectConversationResponse.data:type_name -> iam.v1.ConversationProto + 51, // 6: iam.v1.CreateGroupConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 7: iam.v1.CreateGroupConversationResponse.data:type_name -> iam.v1.ConversationProto + 51, // 8: iam.v1.GetConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 9: iam.v1.GetConversationResponse.data:type_name -> iam.v1.ConversationProto + 51, // 10: iam.v1.ListConversationsResponse.base:type_name -> common.v1.BaseResponse + 0, // 11: iam.v1.ListConversationsResponse.data:type_name -> iam.v1.ConversationProto + 52, // 12: iam.v1.ListConversationsResponse.pagination:type_name -> common.v1.PaginationResponse + 51, // 13: iam.v1.UpdateGroupConversationResponse.base:type_name -> common.v1.BaseResponse + 0, // 14: iam.v1.UpdateGroupConversationResponse.data:type_name -> iam.v1.ConversationProto + 51, // 15: iam.v1.AddParticipantsResponse.base:type_name -> common.v1.BaseResponse + 51, // 16: iam.v1.RemoveParticipantResponse.base:type_name -> common.v1.BaseResponse + 51, // 17: iam.v1.LeaveConversationResponse.base:type_name -> common.v1.BaseResponse + 51, // 18: iam.v1.SendMessageResponse.base:type_name -> common.v1.BaseResponse + 2, // 19: iam.v1.SendMessageResponse.data:type_name -> iam.v1.MessageProto + 51, // 20: iam.v1.EditMessageResponse.base:type_name -> common.v1.BaseResponse + 2, // 21: iam.v1.EditMessageResponse.data:type_name -> iam.v1.MessageProto + 51, // 22: iam.v1.DeleteMessageResponse.base:type_name -> common.v1.BaseResponse + 51, // 23: iam.v1.ListMessagesResponse.base:type_name -> common.v1.BaseResponse + 2, // 24: iam.v1.ListMessagesResponse.data:type_name -> iam.v1.MessageProto + 51, // 25: iam.v1.MarkConversationReadResponse.base:type_name -> common.v1.BaseResponse + 51, // 26: iam.v1.ClearConversationHistoryResponse.base:type_name -> common.v1.BaseResponse + 51, // 27: iam.v1.SetTypingResponse.base:type_name -> common.v1.BaseResponse + 51, // 28: iam.v1.GetMessageEditHistoryResponse.base:type_name -> common.v1.BaseResponse + 5, // 29: iam.v1.GetMessageEditHistoryResponse.data:type_name -> iam.v1.EditHistoryEntryProto + 51, // 30: iam.v1.UploadChatAttachmentResponse.base:type_name -> common.v1.BaseResponse + 3, // 31: iam.v1.UploadChatAttachmentResponse.data:type_name -> iam.v1.AttachmentProto + 51, // 32: iam.v1.HeartbeatResponse.base:type_name -> common.v1.BaseResponse + 51, // 33: iam.v1.GetOnlineUsersResponse.base:type_name -> common.v1.BaseResponse + 46, // 34: iam.v1.StreamChatEventsResponse.message_received:type_name -> iam.v1.MessageEvent + 46, // 35: iam.v1.StreamChatEventsResponse.message_edited:type_name -> iam.v1.MessageEvent + 47, // 36: iam.v1.StreamChatEventsResponse.message_deleted:type_name -> iam.v1.DeleteEvent + 48, // 37: iam.v1.StreamChatEventsResponse.typing:type_name -> iam.v1.TypingEvent + 49, // 38: iam.v1.StreamChatEventsResponse.read_receipt:type_name -> iam.v1.ReadEvent + 50, // 39: iam.v1.StreamChatEventsResponse.presence:type_name -> iam.v1.PresenceEvent + 2, // 40: iam.v1.MessageEvent.message:type_name -> iam.v1.MessageProto + 6, // 41: iam.v1.ChatService.CreateDirectConversation:input_type -> iam.v1.CreateDirectConversationRequest + 7, // 42: iam.v1.ChatService.CreateGroupConversation:input_type -> iam.v1.CreateGroupConversationRequest + 8, // 43: iam.v1.ChatService.GetConversation:input_type -> iam.v1.GetConversationRequest + 9, // 44: iam.v1.ChatService.ListConversations:input_type -> iam.v1.ListConversationsRequest + 10, // 45: iam.v1.ChatService.UpdateGroupConversation:input_type -> iam.v1.UpdateGroupConversationRequest + 11, // 46: iam.v1.ChatService.AddParticipants:input_type -> iam.v1.AddParticipantsRequest + 12, // 47: iam.v1.ChatService.RemoveParticipant:input_type -> iam.v1.RemoveParticipantRequest + 13, // 48: iam.v1.ChatService.LeaveConversation:input_type -> iam.v1.LeaveConversationRequest + 14, // 49: iam.v1.ChatService.SendMessage:input_type -> iam.v1.SendMessageRequest + 15, // 50: iam.v1.ChatService.EditMessage:input_type -> iam.v1.EditMessageRequest + 16, // 51: iam.v1.ChatService.DeleteMessage:input_type -> iam.v1.DeleteMessageRequest + 17, // 52: iam.v1.ChatService.ListMessages:input_type -> iam.v1.ListMessagesRequest + 18, // 53: iam.v1.ChatService.StreamChatEvents:input_type -> iam.v1.StreamChatEventsRequest + 19, // 54: iam.v1.ChatService.MarkConversationRead:input_type -> iam.v1.MarkConversationReadRequest + 20, // 55: iam.v1.ChatService.ClearConversationHistory:input_type -> iam.v1.ClearConversationHistoryRequest + 21, // 56: iam.v1.ChatService.SetTyping:input_type -> iam.v1.SetTypingRequest + 22, // 57: iam.v1.ChatService.GetMessageEditHistory:input_type -> iam.v1.GetMessageEditHistoryRequest + 23, // 58: iam.v1.ChatService.UploadChatAttachment:input_type -> iam.v1.UploadChatAttachmentRequest + 24, // 59: iam.v1.PresenceService.Heartbeat:input_type -> iam.v1.HeartbeatRequest + 25, // 60: iam.v1.PresenceService.GetOnlineUsers:input_type -> iam.v1.GetOnlineUsersRequest + 26, // 61: iam.v1.ChatService.CreateDirectConversation:output_type -> iam.v1.CreateDirectConversationResponse + 27, // 62: iam.v1.ChatService.CreateGroupConversation:output_type -> iam.v1.CreateGroupConversationResponse + 28, // 63: iam.v1.ChatService.GetConversation:output_type -> iam.v1.GetConversationResponse + 29, // 64: iam.v1.ChatService.ListConversations:output_type -> iam.v1.ListConversationsResponse + 30, // 65: iam.v1.ChatService.UpdateGroupConversation:output_type -> iam.v1.UpdateGroupConversationResponse + 31, // 66: iam.v1.ChatService.AddParticipants:output_type -> iam.v1.AddParticipantsResponse + 32, // 67: iam.v1.ChatService.RemoveParticipant:output_type -> iam.v1.RemoveParticipantResponse + 33, // 68: iam.v1.ChatService.LeaveConversation:output_type -> iam.v1.LeaveConversationResponse + 34, // 69: iam.v1.ChatService.SendMessage:output_type -> iam.v1.SendMessageResponse + 35, // 70: iam.v1.ChatService.EditMessage:output_type -> iam.v1.EditMessageResponse + 36, // 71: iam.v1.ChatService.DeleteMessage:output_type -> iam.v1.DeleteMessageResponse + 37, // 72: iam.v1.ChatService.ListMessages:output_type -> iam.v1.ListMessagesResponse + 45, // 73: iam.v1.ChatService.StreamChatEvents:output_type -> iam.v1.StreamChatEventsResponse + 38, // 74: iam.v1.ChatService.MarkConversationRead:output_type -> iam.v1.MarkConversationReadResponse + 39, // 75: iam.v1.ChatService.ClearConversationHistory:output_type -> iam.v1.ClearConversationHistoryResponse + 40, // 76: iam.v1.ChatService.SetTyping:output_type -> iam.v1.SetTypingResponse + 41, // 77: iam.v1.ChatService.GetMessageEditHistory:output_type -> iam.v1.GetMessageEditHistoryResponse + 42, // 78: iam.v1.ChatService.UploadChatAttachment:output_type -> iam.v1.UploadChatAttachmentResponse + 43, // 79: iam.v1.PresenceService.Heartbeat:output_type -> iam.v1.HeartbeatResponse + 44, // 80: iam.v1.PresenceService.GetOnlineUsers:output_type -> iam.v1.GetOnlineUsersResponse + 61, // [61:81] is the sub-list for method output_type + 41, // [41:61] is the sub-list for method input_type + 41, // [41:41] is the sub-list for extension type_name + 41, // [41:41] is the sub-list for extension extendee + 0, // [0:41] is the sub-list for field type_name } func init() { file_iam_v1_chat_proto_init() } @@ -3092,7 +3444,7 @@ func file_iam_v1_chat_proto_init() { if File_iam_v1_chat_proto != nil { return } - file_iam_v1_chat_proto_msgTypes[40].OneofWrappers = []any{ + file_iam_v1_chat_proto_msgTypes[45].OneofWrappers = []any{ (*StreamChatEventsResponse_MessageReceived)(nil), (*StreamChatEventsResponse_MessageEdited)(nil), (*StreamChatEventsResponse_MessageDeleted)(nil), @@ -3106,7 +3458,7 @@ func file_iam_v1_chat_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_iam_v1_chat_proto_rawDesc), len(file_iam_v1_chat_proto_rawDesc)), NumEnums: 0, - NumMessages: 46, + NumMessages: 51, NumExtensions: 0, NumServices: 2, }, diff --git a/gen/iam/v1/chat.pb.gw.go b/gen/iam/v1/chat.pb.gw.go index 247087f2..268937e1 100644 --- a/gen/iam/v1/chat.pb.gw.go +++ b/gen/iam/v1/chat.pb.gw.go @@ -640,6 +640,45 @@ func local_request_ChatService_MarkConversationRead_0(ctx context.Context, marsh return msg, metadata, err } +func request_ChatService_ClearConversationHistory_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ClearConversationHistoryRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.ClearConversationHistory(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_ClearConversationHistory_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ClearConversationHistoryRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.ClearConversationHistory(ctx, &protoReq) + return msg, metadata, err +} + func request_ChatService_SetTyping_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq SetTypingRequest @@ -740,6 +779,51 @@ func local_request_ChatService_GetMessageEditHistory_0(ctx context.Context, mars return msg, metadata, err } +func request_ChatService_UploadChatAttachment_0(ctx context.Context, marshaler runtime.Marshaler, client ChatServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UploadChatAttachmentRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := client.UploadChatAttachment(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_ChatService_UploadChatAttachment_0(ctx context.Context, marshaler runtime.Marshaler, server ChatServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UploadChatAttachmentRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["conversation_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "conversation_id") + } + protoReq.ConversationId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "conversation_id", err) + } + msg, err := server.UploadChatAttachment(ctx, &protoReq) + return msg, metadata, err +} + func request_PresenceService_Heartbeat_0(ctx context.Context, marshaler runtime.Marshaler, client PresenceServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq HeartbeatRequest @@ -1075,6 +1159,26 @@ func RegisterChatServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux } forward_ChatService_MarkConversationRead_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodDelete, pattern_ChatService_ClearConversationHistory_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/ClearConversationHistory", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/history")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_ClearConversationHistory_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_ClearConversationHistory_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodPost, pattern_ChatService_SetTyping_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1115,6 +1219,26 @@ func RegisterChatServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux } forward_ChatService_GetMessageEditHistory_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_ChatService_UploadChatAttachment_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/iam.v1.ChatService/UploadChatAttachment", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/attachments")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ChatService_UploadChatAttachment_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_UploadChatAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1443,6 +1567,23 @@ func RegisterChatServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux } forward_ChatService_MarkConversationRead_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodDelete, pattern_ChatService_ClearConversationHistory_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/ClearConversationHistory", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/history")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_ClearConversationHistory_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_ClearConversationHistory_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodPost, pattern_ChatService_SetTyping_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1477,6 +1618,23 @@ func RegisterChatServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux } forward_ChatService_GetMessageEditHistory_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_ChatService_UploadChatAttachment_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/iam.v1.ChatService/UploadChatAttachment", runtime.WithHTTPPathPattern("/api/v1/iam/chat/conversations/{conversation_id}/attachments")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ChatService_UploadChatAttachment_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_ChatService_UploadChatAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1495,8 +1653,10 @@ var ( pattern_ChatService_ListMessages_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "messages"}, "")) pattern_ChatService_StreamChatEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"api", "v1", "iam", "chat", "events"}, "")) pattern_ChatService_MarkConversationRead_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "read"}, "")) + pattern_ChatService_ClearConversationHistory_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "history"}, "")) pattern_ChatService_SetTyping_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "typing"}, "")) pattern_ChatService_GetMessageEditHistory_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6, 1, 0, 4, 1, 5, 7, 2, 8}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "messages", "message_id", "history"}, "")) + pattern_ChatService_UploadChatAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v1", "iam", "chat", "conversations", "conversation_id", "attachments"}, "")) ) var ( @@ -1514,8 +1674,10 @@ var ( forward_ChatService_ListMessages_0 = runtime.ForwardResponseMessage forward_ChatService_StreamChatEvents_0 = runtime.ForwardResponseStream forward_ChatService_MarkConversationRead_0 = runtime.ForwardResponseMessage + forward_ChatService_ClearConversationHistory_0 = runtime.ForwardResponseMessage forward_ChatService_SetTyping_0 = runtime.ForwardResponseMessage forward_ChatService_GetMessageEditHistory_0 = runtime.ForwardResponseMessage + forward_ChatService_UploadChatAttachment_0 = runtime.ForwardResponseMessage ) // RegisterPresenceServiceHandlerFromEndpoint is same as RegisterPresenceServiceHandler but diff --git a/gen/iam/v1/chat_grpc.pb.go b/gen/iam/v1/chat_grpc.pb.go index f099ea70..311626ec 100644 --- a/gen/iam/v1/chat_grpc.pb.go +++ b/gen/iam/v1/chat_grpc.pb.go @@ -33,8 +33,10 @@ const ( ChatService_ListMessages_FullMethodName = "/iam.v1.ChatService/ListMessages" ChatService_StreamChatEvents_FullMethodName = "/iam.v1.ChatService/StreamChatEvents" ChatService_MarkConversationRead_FullMethodName = "/iam.v1.ChatService/MarkConversationRead" + ChatService_ClearConversationHistory_FullMethodName = "/iam.v1.ChatService/ClearConversationHistory" ChatService_SetTyping_FullMethodName = "/iam.v1.ChatService/SetTyping" ChatService_GetMessageEditHistory_FullMethodName = "/iam.v1.ChatService/GetMessageEditHistory" + ChatService_UploadChatAttachment_FullMethodName = "/iam.v1.ChatService/UploadChatAttachment" ) // ChatServiceClient is the client API for ChatService service. @@ -71,10 +73,16 @@ type ChatServiceClient interface { StreamChatEvents(ctx context.Context, in *StreamChatEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamChatEventsResponse], error) // MarkConversationRead marks all messages in a conversation as read by the current user. MarkConversationRead(ctx context.Context, in *MarkConversationReadRequest, opts ...grpc.CallOption) (*MarkConversationReadResponse, error) + // ClearConversationHistory clears the calling user's own view of a conversation's + // message history. Messages remain visible to other participants. + ClearConversationHistory(ctx context.Context, in *ClearConversationHistoryRequest, opts ...grpc.CallOption) (*ClearConversationHistoryResponse, error) // SetTyping sets the typing indicator for the current user in a conversation. SetTyping(ctx context.Context, in *SetTypingRequest, opts ...grpc.CallOption) (*SetTypingResponse, error) // GetMessageEditHistory returns the edit history for a specific message. GetMessageEditHistory(ctx context.Context, in *GetMessageEditHistoryRequest, opts ...grpc.CallOption) (*GetMessageEditHistoryResponse, error) + // UploadChatAttachment uploads a file to a conversation and returns its + // attachment metadata. The returned attachment_id is passed to SendMessage. + UploadChatAttachment(ctx context.Context, in *UploadChatAttachmentRequest, opts ...grpc.CallOption) (*UploadChatAttachmentResponse, error) } type chatServiceClient struct { @@ -234,6 +242,16 @@ func (c *chatServiceClient) MarkConversationRead(ctx context.Context, in *MarkCo return out, nil } +func (c *chatServiceClient) ClearConversationHistory(ctx context.Context, in *ClearConversationHistoryRequest, opts ...grpc.CallOption) (*ClearConversationHistoryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClearConversationHistoryResponse) + err := c.cc.Invoke(ctx, ChatService_ClearConversationHistory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *chatServiceClient) SetTyping(ctx context.Context, in *SetTypingRequest, opts ...grpc.CallOption) (*SetTypingResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetTypingResponse) @@ -254,6 +272,16 @@ func (c *chatServiceClient) GetMessageEditHistory(ctx context.Context, in *GetMe return out, nil } +func (c *chatServiceClient) UploadChatAttachment(ctx context.Context, in *UploadChatAttachmentRequest, opts ...grpc.CallOption) (*UploadChatAttachmentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UploadChatAttachmentResponse) + err := c.cc.Invoke(ctx, ChatService_UploadChatAttachment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ChatServiceServer is the server API for ChatService service. // All implementations must embed UnimplementedChatServiceServer // for forward compatibility. @@ -288,10 +316,16 @@ type ChatServiceServer interface { StreamChatEvents(*StreamChatEventsRequest, grpc.ServerStreamingServer[StreamChatEventsResponse]) error // MarkConversationRead marks all messages in a conversation as read by the current user. MarkConversationRead(context.Context, *MarkConversationReadRequest) (*MarkConversationReadResponse, error) + // ClearConversationHistory clears the calling user's own view of a conversation's + // message history. Messages remain visible to other participants. + ClearConversationHistory(context.Context, *ClearConversationHistoryRequest) (*ClearConversationHistoryResponse, error) // SetTyping sets the typing indicator for the current user in a conversation. SetTyping(context.Context, *SetTypingRequest) (*SetTypingResponse, error) // GetMessageEditHistory returns the edit history for a specific message. GetMessageEditHistory(context.Context, *GetMessageEditHistoryRequest) (*GetMessageEditHistoryResponse, error) + // UploadChatAttachment uploads a file to a conversation and returns its + // attachment metadata. The returned attachment_id is passed to SendMessage. + UploadChatAttachment(context.Context, *UploadChatAttachmentRequest) (*UploadChatAttachmentResponse, error) mustEmbedUnimplementedChatServiceServer() } @@ -344,12 +378,18 @@ func (UnimplementedChatServiceServer) StreamChatEvents(*StreamChatEventsRequest, func (UnimplementedChatServiceServer) MarkConversationRead(context.Context, *MarkConversationReadRequest) (*MarkConversationReadResponse, error) { return nil, status.Error(codes.Unimplemented, "method MarkConversationRead not implemented") } +func (UnimplementedChatServiceServer) ClearConversationHistory(context.Context, *ClearConversationHistoryRequest) (*ClearConversationHistoryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ClearConversationHistory not implemented") +} func (UnimplementedChatServiceServer) SetTyping(context.Context, *SetTypingRequest) (*SetTypingResponse, error) { return nil, status.Error(codes.Unimplemented, "method SetTyping not implemented") } func (UnimplementedChatServiceServer) GetMessageEditHistory(context.Context, *GetMessageEditHistoryRequest) (*GetMessageEditHistoryResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetMessageEditHistory not implemented") } +func (UnimplementedChatServiceServer) UploadChatAttachment(context.Context, *UploadChatAttachmentRequest) (*UploadChatAttachmentResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UploadChatAttachment not implemented") +} func (UnimplementedChatServiceServer) mustEmbedUnimplementedChatServiceServer() {} func (UnimplementedChatServiceServer) testEmbeddedByValue() {} @@ -616,6 +656,24 @@ func _ChatService_MarkConversationRead_Handler(srv interface{}, ctx context.Cont return interceptor(ctx, in, info, handler) } +func _ChatService_ClearConversationHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClearConversationHistoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).ClearConversationHistory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_ClearConversationHistory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).ClearConversationHistory(ctx, req.(*ClearConversationHistoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _ChatService_SetTyping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SetTypingRequest) if err := dec(in); err != nil { @@ -652,6 +710,24 @@ func _ChatService_GetMessageEditHistory_Handler(srv interface{}, ctx context.Con return interceptor(ctx, in, info, handler) } +func _ChatService_UploadChatAttachment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UploadChatAttachmentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).UploadChatAttachment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_UploadChatAttachment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).UploadChatAttachment(ctx, req.(*UploadChatAttachmentRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ChatService_ServiceDesc is the grpc.ServiceDesc for ChatService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -711,6 +787,10 @@ var ChatService_ServiceDesc = grpc.ServiceDesc{ MethodName: "MarkConversationRead", Handler: _ChatService_MarkConversationRead_Handler, }, + { + MethodName: "ClearConversationHistory", + Handler: _ChatService_ClearConversationHistory_Handler, + }, { MethodName: "SetTyping", Handler: _ChatService_SetTyping_Handler, @@ -719,6 +799,10 @@ var ChatService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetMessageEditHistory", Handler: _ChatService_GetMessageEditHistory_Handler, }, + { + MethodName: "UploadChatAttachment", + Handler: _ChatService_UploadChatAttachment_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/gen/openapi/iam/v1/chat.swagger.json b/gen/openapi/iam/v1/chat.swagger.json index 0fe2fa37..a5296706 100644 --- a/gen/openapi/iam/v1/chat.swagger.json +++ b/gen/openapi/iam/v1/chat.swagger.json @@ -200,6 +200,76 @@ ] } }, + "/api/v1/iam/chat/conversations/{conversationId}/attachments": { + "post": { + "summary": "UploadChatAttachment uploads a file to a conversation and returns its\nattachment metadata. The returned attachment_id is passed to SendMessage.", + "operationId": "ChatService_UploadChatAttachment", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1UploadChatAttachmentResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ChatServiceUploadChatAttachmentBody" + } + } + ], + "tags": [ + "ChatService" + ] + } + }, + "/api/v1/iam/chat/conversations/{conversationId}/history": { + "delete": { + "summary": "ClearConversationHistory clears the calling user's own view of a conversation's\nmessage history. Messages remain visible to other participants.", + "operationId": "ChatService_ClearConversationHistory", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ClearConversationHistoryResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "ChatService" + ] + } + }, "/api/v1/iam/chat/conversations/{conversationId}/leave": { "post": { "summary": "LeaveConversation allows the current user to leave a group conversation.", @@ -742,6 +812,13 @@ "replyToId": { "type": "string", "description": "ID of the message being replied to (optional, empty = no reply)." + }, + "attachmentIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pre-uploaded attachment IDs to associate with this message (optional)." } }, "description": "SendMessageRequest sends a message to a conversation." @@ -767,6 +844,23 @@ }, "description": "UpdateGroupConversationRequest updates a group conversation." }, + "ChatServiceUploadChatAttachmentBody": { + "type": "object", + "properties": { + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "fileData": { + "type": "string", + "format": "byte", + "description": "Raw file bytes (max 25MB)." + } + }, + "description": "UploadChatAttachmentRequest uploads a single file to a conversation." + }, "protobufAny": { "type": "object", "properties": { @@ -804,6 +898,31 @@ }, "description": "AddParticipantsResponse confirms participants were added." }, + "v1AttachmentProto": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "fileUrl": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "fileSize": { + "type": "string", + "format": "int64" + }, + "thumbnailUrl": { + "type": "string" + } + }, + "description": "AttachmentProto represents a file or image attached to a chat message." + }, "v1BaseResponse": { "type": "object", "properties": { @@ -830,6 +949,15 @@ }, "description": "BaseResponse is the standard response wrapper for all API responses." }, + "v1ClearConversationHistoryResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + } + }, + "description": "ClearConversationHistoryResponse confirms the caller's history was cleared." + }, "v1ConversationProto": { "type": "object", "properties": { @@ -1134,6 +1262,13 @@ }, "updatedAt": { "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1AttachmentProto" + } } }, "description": "MessageProto represents a single chat message with decrypted body." @@ -1320,6 +1455,18 @@ }, "description": "UpdateGroupConversationResponse returns the updated group conversation." }, + "v1UploadChatAttachmentResponse": { + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/v1BaseResponse" + }, + "data": { + "$ref": "#/definitions/v1AttachmentProto" + } + }, + "description": "UploadChatAttachmentResponse returns the uploaded attachment metadata." + }, "v1ValidationError": { "type": "object", "properties": { diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index 1882264b..898bec65 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -235,8 +235,10 @@ func run() error { chatConvRepo := postgres.NewChatConversationRepository(db) chatMsgRepo := postgres.NewChatMessageRepository(db) chatReceiptRepo := postgres.NewChatReadReceiptRepository(db) + chatAttRepo := postgres.NewChatAttachmentRepository(db) chatUserResolver := postgres.NewChatUserResolver(db) sendMsgHandler := appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster) + sendMsgHandler.WithAttachments(chatAttRepo) if presenceSvc != nil && redisClient != nil { sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, notifEmailDispatcher, redisClient.Client, chatUserResolver) } @@ -254,6 +256,9 @@ func run() error { appChat.NewSetTypingHandler(chatConvRepo, presenceSvc, chatBroadcaster), appChat.NewStreamHandler(chatBroadcaster), appChat.NewGetEditHistoryHandler(chatConvRepo, chatMsgRepo, chatEnc), + appChat.NewClearHistoryHandler(chatConvRepo), + appChat.NewUploadAttachmentHandler(chatConvRepo, chatAttRepo, storageSvc), + chatAttRepo, chatUserResolver, ) if presenceSvc != nil { diff --git a/services/iam/internal/application/chat/broadcast_helpers.go b/services/iam/internal/application/chat/broadcast_helpers.go index 5e65d86d..89e1cd97 100644 --- a/services/iam/internal/application/chat/broadcast_helpers.go +++ b/services/iam/internal/application/chat/broadcast_helpers.go @@ -42,9 +42,10 @@ func resolveSenderName(ctx context.Context, userResolver *postgres.ChatUserResol // broadcastMessageEvent publishes a message-related event (created, edited) to // every active participant of conv via the given broadcaster. -func broadcastMessageEvent(broadcaster *chatinfra.Broadcaster, conv *domainChat.Conversation, msg *domainChat.Message, plainBody, eventType, senderName string) { +func broadcastMessageEvent(broadcaster *chatinfra.Broadcaster, conv *domainChat.Conversation, msg *domainChat.Message, plainBody, eventType, senderName string, attachments []*iamv1.AttachmentProto) { msgProto := domainMsgToProto(msg, plainBody) msgProto.SenderName = senderName + msgProto.Attachments = attachments eventID := fmt.Sprintf("msg-%s", msg.MessageID()) var resp iamv1.StreamChatEventsResponse @@ -72,6 +73,18 @@ func broadcastMessageEvent(broadcaster *chatinfra.Broadcaster, conv *domainChat. } } +// attachmentToProto maps a domain attachment to its proto representation. +func attachmentToProto(a *domainChat.Attachment) *iamv1.AttachmentProto { + return &iamv1.AttachmentProto{ + AttachmentId: a.AttachmentID().String(), + FileName: a.FileName(), + FileUrl: a.FileURL(), + ContentType: a.ContentType(), + FileSize: a.FileSize(), + ThumbnailUrl: a.ThumbnailURL(), + } +} + func domainMsgToProto(msg *domainChat.Message, plainBody string) *iamv1.MessageProto { replyTo := "" if msg.ReplyToID() != uuid.Nil { diff --git a/services/iam/internal/application/chat/clear_history_handler.go b/services/iam/internal/application/chat/clear_history_handler.go new file mode 100644 index 00000000..80182027 --- /dev/null +++ b/services/iam/internal/application/chat/clear_history_handler.go @@ -0,0 +1,34 @@ +package chat + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// ClearHistoryHandler clears the calling user's own view of a conversation's +// message history. Messages remain visible to other participants. +type ClearHistoryHandler struct { + convRepo chat.ConversationRepository +} + +// NewClearHistoryHandler constructs the handler. +func NewClearHistoryHandler(convRepo chat.ConversationRepository) *ClearHistoryHandler { + return &ClearHistoryHandler{convRepo: convRepo} +} + +// Handle verifies callerID is an active participant, then clears their history. +func (h *ClearHistoryHandler) Handle(ctx context.Context, callerID, convID uuid.UUID) error { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return err + } + p := conv.FindParticipant(callerID) + if p == nil || !p.IsActive() { + return fmt.Errorf("clear history: %w", chat.ErrNotParticipant) + } + return h.convRepo.ClearHistory(ctx, convID, callerID) +} diff --git a/services/iam/internal/application/chat/edit_message_handler.go b/services/iam/internal/application/chat/edit_message_handler.go index 5634f687..504febc8 100644 --- a/services/iam/internal/application/chat/edit_message_handler.go +++ b/services/iam/internal/application/chat/edit_message_handler.go @@ -73,6 +73,6 @@ func (h *EditMessageHandler) Handle(ctx context.Context, callerID, convID, msgID return nil, fmt.Errorf("edit message: update: %w", err) } senderName := resolveSenderName(ctx, h.userResolver, msg.SenderUserID()) - broadcastMessageEvent(h.broadcaster, conv, msg, newBody, "message_edited", senderName) + broadcastMessageEvent(h.broadcaster, conv, msg, newBody, "message_edited", senderName, nil) return msg, nil } diff --git a/services/iam/internal/application/chat/list_messages_handler.go b/services/iam/internal/application/chat/list_messages_handler.go index 6204c9a6..af7b2edc 100644 --- a/services/iam/internal/application/chat/list_messages_handler.go +++ b/services/iam/internal/application/chat/list_messages_handler.go @@ -53,7 +53,7 @@ func (h *ListMessagesHandler) Handle(ctx context.Context, callerID, convID uuid. return &MessagesResult{Messages: []*DecryptedMessage{}, NextCursor: "", HasMore: false}, nil } - msgs, nextCursor, hasMore, err := h.msgRepo.ListByConversation(ctx, convID, pageSize, beforeCursor) + msgs, nextCursor, hasMore, err := h.msgRepo.ListByConversation(ctx, convID, pageSize, beforeCursor, p.HistoryClearedAt()) if err != nil { return nil, fmt.Errorf("list messages: %w", err) } diff --git a/services/iam/internal/application/chat/mark_read_handler.go b/services/iam/internal/application/chat/mark_read_handler.go index c8804c1f..d86d2c04 100644 --- a/services/iam/internal/application/chat/mark_read_handler.go +++ b/services/iam/internal/application/chat/mark_read_handler.go @@ -42,8 +42,10 @@ func (h *MarkReadHandler) Handle(ctx context.Context, callerID, convID uuid.UUID return fmt.Errorf("mark read: %w", chat.ErrNotParticipant) } - // Fetch recent messages (up to last 100) and upsert receipts. - msgs, _, _, err := h.msgRepo.ListByConversation(ctx, convID, 100, "") + // Fetch recent messages (up to last 100) and upsert receipts. Messages the + // caller has cleared from their own view are excluded — no point marking + // hidden history as read. + msgs, _, _, err := h.msgRepo.ListByConversation(ctx, convID, 100, "", p.HistoryClearedAt()) if err != nil { return fmt.Errorf("mark read: list: %w", err) } diff --git a/services/iam/internal/application/chat/send_message_handler.go b/services/iam/internal/application/chat/send_message_handler.go index 8a85b83d..7a634a88 100644 --- a/services/iam/internal/application/chat/send_message_handler.go +++ b/services/iam/internal/application/chat/send_message_handler.go @@ -9,6 +9,7 @@ import ( "github.com/redis/go-redis/v9" "github.com/rs/zerolog/log" + iamv1 "github.com/mutugading/goapps-backend/gen/iam/v1" appnotif "github.com/mutugading/goapps-backend/services/iam/internal/application/notification" "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" "github.com/mutugading/goapps-backend/services/iam/internal/domain/notification" @@ -29,6 +30,7 @@ type SendMessageHandler struct { emailDispatch appnotif.EmailDispatcher rdb *redis.Client userResolver *postgres.ChatUserResolver + attRepo chat.AttachmentRepository } // NewSendMessageHandler constructs the handler. @@ -42,6 +44,12 @@ func NewSendMessageHandler( return &SendMessageHandler{convRepo: convRepo, msgRepo: msgRepo, receiptRepo: receiptRepo, enc: enc, broadcaster: broadcaster} } +// WithAttachments enables linking uploaded attachments to sent messages. +func (h *SendMessageHandler) WithAttachments(attRepo chat.AttachmentRepository) *SendMessageHandler { + h.attRepo = attRepo + return h +} + // WithOfflineNotification enables email notifications for offline users. func (h *SendMessageHandler) WithOfflineNotification(presence *chatinfra.PresenceService, notifCreate *appnotif.CreateHandler, emailDispatch appnotif.EmailDispatcher, rdb *redis.Client, userResolver *postgres.ChatUserResolver) *SendMessageHandler { h.presence = presence @@ -52,34 +60,36 @@ func (h *SendMessageHandler) WithOfflineNotification(presence *chatinfra.Presenc return h } -// Handle validates participation, encrypts body, saves, and broadcasts. -func (h *SendMessageHandler) Handle(ctx context.Context, senderID, convID uuid.UUID, body string, replyToID uuid.UUID) (*chat.Message, error) { +// Handle validates participation, encrypts body, saves, and broadcasts. Any +// attachmentIDs that belong to this conversation and are not yet linked are +// attached to the new message. Returns the message plus its attachment protos. +func (h *SendMessageHandler) Handle(ctx context.Context, senderID, convID uuid.UUID, body string, replyToID uuid.UUID, attachmentIDs []uuid.UUID) (*chat.Message, []*iamv1.AttachmentProto, error) { conv, err := h.convRepo.GetByID(ctx, convID) if err != nil { - return nil, err + return nil, nil, err } p := conv.FindParticipant(senderID) if p == nil || !p.IsActive() { - return nil, fmt.Errorf("send message: %w", chat.ErrNotParticipant) + return nil, nil, fmt.Errorf("send message: %w", chat.ErrNotParticipant) } convKeyPlain, err := h.enc.DecryptConversationKey(conv.EncryptionKey()) if err != nil { - return nil, fmt.Errorf("send message: decrypt conv key: %w", err) + return nil, nil, fmt.Errorf("send message: decrypt conv key: %w", err) } bodyEnc, err := h.enc.EncryptMessage(convKeyPlain, body) if err != nil { - return nil, fmt.Errorf("send message: encrypt body: %w", err) + return nil, nil, fmt.Errorf("send message: encrypt body: %w", err) } bodyPlainEnc, err := h.enc.EncryptMessage(convKeyPlain, body) // same body, independent nonce for search index slot if err != nil { - return nil, fmt.Errorf("send message: encrypt plain: %w", err) + return nil, nil, fmt.Errorf("send message: encrypt plain: %w", err) } msg := chat.NewMessage(convID, senderID, bodyEnc, bodyPlainEnc, replyToID) if err := h.msgRepo.Create(ctx, msg); err != nil { - return nil, fmt.Errorf("send message: persist: %w", err) + return nil, nil, fmt.Errorf("send message: persist: %w", err) } // Auto-read for sender. @@ -87,11 +97,44 @@ func (h *SendMessageHandler) Handle(ctx context.Context, senderID, convID uuid.U log.Warn().Err(err).Msg("send message: auto read receipt failed") } + attProtos, err := h.linkAttachments(ctx, convID, msg.MessageID(), attachmentIDs) + if err != nil { + return nil, nil, err + } + senderName := resolveSenderName(ctx, h.userResolver, senderID) - broadcastMessageEvent(h.broadcaster, conv, msg, body, "message_received", senderName) + broadcastMessageEvent(h.broadcaster, conv, msg, body, "message_received", senderName, attProtos) h.notifyOfflineParticipants(ctx, conv, msg, senderID, senderName, body) - return msg, nil + return msg, attProtos, nil +} + +// linkAttachments links pending attachments (belonging to convID and not yet +// linked) to messageID and returns their proto representations. +func (h *SendMessageHandler) linkAttachments(ctx context.Context, convID, messageID uuid.UUID, attachmentIDs []uuid.UUID) ([]*iamv1.AttachmentProto, error) { + if len(attachmentIDs) == 0 || h.attRepo == nil { + return nil, nil + } + found, err := h.attRepo.GetByIDs(ctx, attachmentIDs) + if err != nil { + return nil, fmt.Errorf("send message: load attachments: %w", err) + } + validIDs := make([]uuid.UUID, 0, len(found)) + protos := make([]*iamv1.AttachmentProto, 0, len(found)) + for _, att := range found { + if att.ConversationID() != convID || att.MessageID() != nil { + continue + } + validIDs = append(validIDs, att.AttachmentID()) + protos = append(protos, attachmentToProto(att)) + } + if len(validIDs) == 0 { + return nil, nil + } + if err := h.attRepo.LinkToMessage(ctx, messageID, validIDs); err != nil { + return nil, fmt.Errorf("send message: link attachments: %w", err) + } + return protos, nil } const emailDebounceTTL = 5 * time.Minute diff --git a/services/iam/internal/application/chat/upload_attachment_handler.go b/services/iam/internal/application/chat/upload_attachment_handler.go new file mode 100644 index 00000000..ea231f02 --- /dev/null +++ b/services/iam/internal/application/chat/upload_attachment_handler.go @@ -0,0 +1,57 @@ +package chat + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" + storageinfra "github.com/mutugading/goapps-backend/services/iam/internal/infrastructure/storage" +) + +// UploadAttachmentHandler uploads a file and records an unlinked attachment. +type UploadAttachmentHandler struct { + convRepo chat.ConversationRepository + attRepo chat.AttachmentRepository + storage storageinfra.Service +} + +// NewUploadAttachmentHandler constructs the handler. +func NewUploadAttachmentHandler(convRepo chat.ConversationRepository, attRepo chat.AttachmentRepository, storage storageinfra.Service) *UploadAttachmentHandler { + return &UploadAttachmentHandler{convRepo: convRepo, attRepo: attRepo, storage: storage} +} + +// Handle validates participation, uploads the file, and persists an attachment +// that is not yet linked to any message. +func (h *UploadAttachmentHandler) Handle(ctx context.Context, uploaderID, convID uuid.UUID, fileName, contentType string, data []byte) (*chat.Attachment, error) { + conv, err := h.convRepo.GetByID(ctx, convID) + if err != nil { + return nil, err + } + p := conv.FindParticipant(uploaderID) + if p == nil || !p.IsActive() { + return nil, fmt.Errorf("upload attachment: %w", chat.ErrNotParticipant) + } + if h.storage == nil { + return nil, fmt.Errorf("upload attachment: %w", chat.ErrStorageUnavailable) + } + + fileURL, err := h.storage.UploadChatAttachment(ctx, convID.String(), fileName, bytes.NewReader(data), int64(len(data)), contentType) + if err != nil { + return nil, fmt.Errorf("upload attachment: store file: %w", err) + } + + thumbnailURL := "" + if strings.HasPrefix(contentType, "image/") { + thumbnailURL = fileURL + } + + att := chat.NewAttachment(convID, uploaderID, fileName, fileURL, contentType, int64(len(data)), thumbnailURL) + if err := h.attRepo.Create(ctx, att); err != nil { + return nil, fmt.Errorf("upload attachment: persist: %w", err) + } + return att, nil +} diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index 89826606..29b0dc31 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -33,6 +33,9 @@ type ChatHandler struct { setTyping *appChat.SetTypingHandler stream *appChat.StreamHandler editHistory *appChat.GetEditHistoryHandler + clearHistory *appChat.ClearHistoryHandler + uploadAttach *appChat.UploadAttachmentHandler + attRepo chat.AttachmentRepository userResolver *postgres.ChatUserResolver } @@ -51,6 +54,9 @@ func NewChatHandler( setTyping *appChat.SetTypingHandler, stream *appChat.StreamHandler, editHistory *appChat.GetEditHistoryHandler, + clearHistory *appChat.ClearHistoryHandler, + uploadAttach *appChat.UploadAttachmentHandler, + attRepo chat.AttachmentRepository, userResolver *postgres.ChatUserResolver, ) *ChatHandler { return &ChatHandler{ @@ -58,7 +64,9 @@ func NewChatHandler( getConv: getConv, listConvs: listConvs, leaveConv: leaveConv, sendMsg: sendMsg, editMsg: editMsg, deleteMsg: deleteMsg, listMsgs: listMsgs, markRead: markRead, setTyping: setTyping, - stream: stream, editHistory: editHistory, userResolver: userResolver, + stream: stream, editHistory: editHistory, clearHistory: clearHistory, + uploadAttach: uploadAttach, attRepo: attRepo, + userResolver: userResolver, } } @@ -184,11 +192,21 @@ func (h *ChatHandler) SendMessage(ctx context.Context, req *iamv1.SendMessageReq return nil, status.Errorf(codes.InvalidArgument, "invalid reply_to_id: %v", err) } } - msg, err := h.sendMsg.Handle(ctx, callerID, convID, req.GetBody(), replyToID) + attachmentIDs := make([]uuid.UUID, 0, len(req.GetAttachmentIds())) + for _, idStr := range req.GetAttachmentIds() { + aid, parseErr := uuid.Parse(idStr) + if parseErr != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid attachment_id: %v", parseErr) + } + attachmentIDs = append(attachmentIDs, aid) + } + msg, attProtos, err := h.sendMsg.Handle(ctx, callerID, convID, req.GetBody(), replyToID, attachmentIDs) if err != nil { return nil, mapChatError(err) } - return &iamv1.SendMessageResponse{Base: chatSuccessBase(), Data: msgToProto(msg, req.GetBody(), nil)}, nil + mp := msgToProto(msg, req.GetBody(), nil) + mp.Attachments = attProtos + return &iamv1.SendMessageResponse{Base: chatSuccessBase(), Data: mp}, nil } // EditMessage edits an existing message. @@ -256,6 +274,19 @@ func (h *ChatHandler) ListMessages(ctx context.Context, req *iamv1.ListMessagesR } senderMap, _ := h.userResolver.ResolveUsers(ctx, senderIDs) + msgIDs := make([]uuid.UUID, 0, len(result.Messages)) + for _, dm := range result.Messages { + msgIDs = append(msgIDs, dm.MessageID()) + } + attByMsg := map[uuid.UUID][]*chat.Attachment{} + if h.attRepo != nil { + if loaded, attErr := h.attRepo.ListByMessageIDs(ctx, msgIDs); attErr != nil { + log.Warn().Err(attErr).Msg("chat handler: load attachments for list messages") + } else { + attByMsg = loaded + } + } + protos := make([]*iamv1.MessageProto, 0, len(result.Messages)) for _, dm := range result.Messages { mp := msgToProto(dm.Message, dm.PlainBody, dm.ReadReceipts()) @@ -266,6 +297,9 @@ func (h *ChatHandler) ListMessages(ctx context.Context, req *iamv1.ListMessagesR mp.SenderName = info.Username } } + for _, att := range attByMsg[dm.MessageID()] { + mp.Attachments = append(mp.Attachments, attachmentToProto(att)) + } protos = append(protos, mp) } return &iamv1.ListMessagesResponse{ @@ -322,6 +356,41 @@ func (h *ChatHandler) MarkConversationRead(ctx context.Context, req *iamv1.MarkC return &iamv1.MarkConversationReadResponse{Base: chatSuccessBase()}, nil } +// ClearConversationHistory clears the calling user's own view of a +// conversation's message history. Other participants are unaffected. +func (h *ChatHandler) ClearConversationHistory(ctx context.Context, req *iamv1.ClearConversationHistoryRequest) (*iamv1.ClearConversationHistoryResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + if err := h.clearHistory.Handle(ctx, callerID, convID); err != nil { + return nil, mapChatError(err) + } + return &iamv1.ClearConversationHistoryResponse{Base: chatSuccessBase()}, nil +} + +// UploadChatAttachment uploads a file to a conversation, returning an +// attachment that can later be linked to a message via SendMessage. +func (h *ChatHandler) UploadChatAttachment(ctx context.Context, req *iamv1.UploadChatAttachmentRequest) (*iamv1.UploadChatAttachmentResponse, error) { + callerID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + convID, err := uuid.Parse(req.GetConversationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid conversation_id: %v", err) + } + att, err := h.uploadAttach.Handle(ctx, callerID, convID, req.GetFileName(), req.GetContentType(), req.GetFileData()) + if err != nil { + return nil, mapChatError(err) + } + return &iamv1.UploadChatAttachmentResponse{Base: chatSuccessBase(), Data: attachmentToProto(att)}, nil +} + // SetTyping sets the typing indicator. func (h *ChatHandler) SetTyping(ctx context.Context, req *iamv1.SetTypingRequest) (*iamv1.SetTypingResponse, error) { callerID, err := getUserIDFromContext(ctx) @@ -431,14 +500,27 @@ func msgToProto(msg *chat.Message, plainBody string, receipts []*chat.ReadReceip } } +func attachmentToProto(a *chat.Attachment) *iamv1.AttachmentProto { + return &iamv1.AttachmentProto{ + AttachmentId: a.AttachmentID().String(), + FileName: a.FileName(), + FileUrl: a.FileURL(), + ContentType: a.ContentType(), + FileSize: a.FileSize(), + ThumbnailUrl: a.ThumbnailURL(), + } +} + func chatSuccessBase() *commonv1.BaseResponse { return &commonv1.BaseResponse{IsSuccess: true, Message: "success", StatusCode: "200"} } func mapChatError(err error) error { switch { - case errors.Is(err, chat.ErrConversationNotFound), errors.Is(err, chat.ErrMessageNotFound): + case errors.Is(err, chat.ErrConversationNotFound), errors.Is(err, chat.ErrMessageNotFound), errors.Is(err, chat.ErrAttachmentNotFound): return status.Errorf(codes.NotFound, "%v", err) + case errors.Is(err, chat.ErrStorageUnavailable): + return status.Errorf(codes.Unavailable, "%v", err) case errors.Is(err, chat.ErrNotParticipant), errors.Is(err, chat.ErrNotAuthor), errors.Is(err, chat.ErrNotAdmin): return status.Errorf(codes.PermissionDenied, "%v", err) case errors.Is(err, chat.ErrDirectConversationFull), errors.Is(err, chat.ErrAlreadyParticipant): diff --git a/services/iam/internal/delivery/grpc/permission_interceptor.go b/services/iam/internal/delivery/grpc/permission_interceptor.go index bc3dde30..267204c7 100644 --- a/services/iam/internal/delivery/grpc/permission_interceptor.go +++ b/services/iam/internal/delivery/grpc/permission_interceptor.go @@ -235,6 +235,8 @@ var methodPermissions = map[string]PermissionRequirement{ "/iam.v1.ChatService/MarkConversationRead": {Permission: ""}, "/iam.v1.ChatService/SetTyping": {Permission: ""}, "/iam.v1.ChatService/GetMessageEditHistory": {Permission: ""}, + "/iam.v1.ChatService/ClearConversationHistory": {Permission: ""}, + "/iam.v1.ChatService/UploadChatAttachment": {Permission: ""}, // Presence Service — authenticated only. "/iam.v1.PresenceService/Heartbeat": {Permission: ""}, diff --git a/services/iam/internal/domain/chat/attachment.go b/services/iam/internal/domain/chat/attachment.go new file mode 100644 index 00000000..15a84b5f --- /dev/null +++ b/services/iam/internal/domain/chat/attachment.go @@ -0,0 +1,93 @@ +// Package chat contains the domain model for the chat feature: conversations, +// messages, read receipts, and attachments. +package chat + +import ( + "time" + + "github.com/google/uuid" +) + +// Attachment is the aggregate root for a chat file or image attachment. +type Attachment struct { + attachmentID uuid.UUID + conversationID uuid.UUID + messageID *uuid.UUID + uploaderUserID uuid.UUID + fileName string + fileURL string + contentType string + fileSize int64 + thumbnailURL string + createdAt time.Time +} + +// NewAttachment creates a new attachment not yet linked to a message. +func NewAttachment(convID, uploaderID uuid.UUID, fileName, fileURL, contentType string, fileSize int64, thumbnailURL string) *Attachment { + return &Attachment{ + attachmentID: uuid.New(), + conversationID: convID, + messageID: nil, + uploaderUserID: uploaderID, + fileName: fileName, + fileURL: fileURL, + contentType: contentType, + fileSize: fileSize, + thumbnailURL: thumbnailURL, + createdAt: time.Now().UTC(), + } +} + +// ReconstructAttachment rebuilds an Attachment from persistence. +func ReconstructAttachment( + attachmentID, conversationID uuid.UUID, messageID *uuid.UUID, uploaderUserID uuid.UUID, + fileName, fileURL, contentType string, fileSize int64, thumbnailURL string, createdAt time.Time, +) *Attachment { + return &Attachment{ + attachmentID: attachmentID, + conversationID: conversationID, + messageID: messageID, + uploaderUserID: uploaderUserID, + fileName: fileName, + fileURL: fileURL, + contentType: contentType, + fileSize: fileSize, + thumbnailURL: thumbnailURL, + createdAt: createdAt, + } +} + +// AttachmentID returns the attachment ID. +func (a *Attachment) AttachmentID() uuid.UUID { return a.attachmentID } + +// ConversationID returns the conversation ID this attachment belongs to. +func (a *Attachment) ConversationID() uuid.UUID { return a.conversationID } + +// MessageID returns the linked message ID, or nil if not yet linked. +func (a *Attachment) MessageID() *uuid.UUID { return a.messageID } + +// UploaderUserID returns the ID of the user who uploaded the attachment. +func (a *Attachment) UploaderUserID() uuid.UUID { return a.uploaderUserID } + +// FileName returns the original file name. +func (a *Attachment) FileName() string { return a.fileName } + +// FileURL returns the stored file URL. +func (a *Attachment) FileURL() string { return a.fileURL } + +// ContentType returns the MIME content type. +func (a *Attachment) ContentType() string { return a.contentType } + +// FileSize returns the file size in bytes. +func (a *Attachment) FileSize() int64 { return a.fileSize } + +// ThumbnailURL returns the thumbnail URL, or an empty string if none. +func (a *Attachment) ThumbnailURL() string { return a.thumbnailURL } + +// CreatedAt returns the creation timestamp. +func (a *Attachment) CreatedAt() time.Time { return a.createdAt } + +// LinkToMessage links the attachment to a persisted message. +func (a *Attachment) LinkToMessage(msgID uuid.UUID) { + a.messageID = &msgID +} diff --git a/services/iam/internal/domain/chat/attachment_test.go b/services/iam/internal/domain/chat/attachment_test.go new file mode 100644 index 00000000..50ee9b70 --- /dev/null +++ b/services/iam/internal/domain/chat/attachment_test.go @@ -0,0 +1,88 @@ +package chat + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAttachment(t *testing.T) { + convID := uuid.New() + uploaderID := uuid.New() + + tests := []struct { + name string + fileName string + fileURL string + contentType string + fileSize int64 + thumbnailURL string + }{ + { + name: "image with thumbnail", + fileName: "photo.png", + fileURL: "https://cdn/iam/chat/attachments/x/y.png", + contentType: "image/png", + fileSize: 2048, + thumbnailURL: "https://cdn/iam/chat/attachments/x/y.png", + }, + { + name: "document without thumbnail", + fileName: "report.pdf", + fileURL: "https://cdn/iam/chat/attachments/x/z.pdf", + contentType: "application/pdf", + fileSize: 4096, + thumbnailURL: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewAttachment(convID, uploaderID, tt.fileName, tt.fileURL, tt.contentType, tt.fileSize, tt.thumbnailURL) + require.NotNil(t, a) + + assert.NotEqual(t, uuid.Nil, a.AttachmentID()) + assert.Equal(t, convID, a.ConversationID()) + assert.Equal(t, uploaderID, a.UploaderUserID()) + assert.Equal(t, tt.fileName, a.FileName()) + assert.Equal(t, tt.fileURL, a.FileURL()) + assert.Equal(t, tt.contentType, a.ContentType()) + assert.Equal(t, tt.fileSize, a.FileSize()) + assert.Equal(t, tt.thumbnailURL, a.ThumbnailURL()) + assert.Nil(t, a.MessageID(), "new attachment must be unlinked") + assert.False(t, a.CreatedAt().IsZero()) + }) + } +} + +func TestAttachment_LinkToMessage(t *testing.T) { + a := NewAttachment(uuid.New(), uuid.New(), "f.txt", "url", "text/plain", 10, "") + require.Nil(t, a.MessageID()) + + msgID := uuid.New() + a.LinkToMessage(msgID) + + require.NotNil(t, a.MessageID()) + assert.Equal(t, msgID, *a.MessageID()) +} + +func TestReconstructAttachment(t *testing.T) { + attID := uuid.New() + convID := uuid.New() + msgID := uuid.New() + uploaderID := uuid.New() + a := NewAttachment(convID, uploaderID, "f.txt", "url", "text/plain", 10, "") + createdAt := a.CreatedAt() + + got := ReconstructAttachment(attID, convID, &msgID, uploaderID, "f.txt", "url", "text/plain", 10, "thumb", createdAt) + + assert.Equal(t, attID, got.AttachmentID()) + assert.Equal(t, convID, got.ConversationID()) + require.NotNil(t, got.MessageID()) + assert.Equal(t, msgID, *got.MessageID()) + assert.Equal(t, uploaderID, got.UploaderUserID()) + assert.Equal(t, "thumb", got.ThumbnailURL()) + assert.Equal(t, createdAt, got.CreatedAt()) +} diff --git a/services/iam/internal/domain/chat/entity.go b/services/iam/internal/domain/chat/entity.go index ff8dc549..1c79db6e 100644 --- a/services/iam/internal/domain/chat/entity.go +++ b/services/iam/internal/domain/chat/entity.go @@ -8,19 +8,21 @@ import ( // Participant is a member of a conversation. type Participant struct { - conversationID uuid.UUID - userID uuid.UUID - role Role - joinedAt time.Time - leftAt *time.Time - lastReadAt *time.Time + conversationID uuid.UUID + userID uuid.UUID + role Role + joinedAt time.Time + leftAt *time.Time + lastReadAt *time.Time + historyClearedAt *time.Time } // ReconstructParticipant rebuilds a Participant from persistence. -func ReconstructParticipant(conversationID, userID uuid.UUID, role Role, joinedAt time.Time, leftAt, lastReadAt *time.Time) *Participant { +func ReconstructParticipant(conversationID, userID uuid.UUID, role Role, joinedAt time.Time, leftAt, lastReadAt, historyClearedAt *time.Time) *Participant { return &Participant{ conversationID: conversationID, userID: userID, role: role, joinedAt: joinedAt, leftAt: leftAt, lastReadAt: lastReadAt, + historyClearedAt: historyClearedAt, } } @@ -39,6 +41,10 @@ func (p *Participant) LeftAt() *time.Time { return p.leftAt } // LastReadAt returns the last read timestamp, or nil. func (p *Participant) LastReadAt() *time.Time { return p.lastReadAt } +// HistoryClearedAt returns the timestamp at which this participant last +// cleared their own view of the conversation history, or nil if never cleared. +func (p *Participant) HistoryClearedAt() *time.Time { return p.historyClearedAt } + // IsActive returns true if the participant has not left. func (p *Participant) IsActive() bool { return p.leftAt == nil } diff --git a/services/iam/internal/domain/chat/errors.go b/services/iam/internal/domain/chat/errors.go index 1e5b4187..9837f6ff 100644 --- a/services/iam/internal/domain/chat/errors.go +++ b/services/iam/internal/domain/chat/errors.go @@ -28,3 +28,9 @@ var ErrMessageDeleted = errors.New("message has been deleted") // ErrParticipantLeft indicates the participant already left. var ErrParticipantLeft = errors.New("participant has already left the conversation") + +// ErrAttachmentNotFound indicates the attachment does not exist. +var ErrAttachmentNotFound = errors.New("attachment not found") + +// ErrStorageUnavailable indicates the file storage service is not configured. +var ErrStorageUnavailable = errors.New("file storage is not available") diff --git a/services/iam/internal/domain/chat/repository.go b/services/iam/internal/domain/chat/repository.go index be72e35d..079c5210 100644 --- a/services/iam/internal/domain/chat/repository.go +++ b/services/iam/internal/domain/chat/repository.go @@ -36,6 +36,11 @@ type ConversationRepository interface { // GetUnreadCounts returns, for each conversation ID, the count of messages // created after userID's last_read_at (excluding userID's own messages). GetUnreadCounts(ctx context.Context, conversationIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]int32, error) + + // ClearHistory sets history_cleared_at = NOW() for userID's participant row, + // hiding all prior messages from that user's view only. Other participants + // are unaffected. + ClearHistory(ctx context.Context, conversationID, userID uuid.UUID) error } // MessageRepository handles persistence for Message aggregates. @@ -47,7 +52,9 @@ type MessageRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*Message, error) // ListByConversation returns messages using cursor-based pagination (newest first). - ListByConversation(ctx context.Context, conversationID uuid.UUID, pageSize int, beforeCursor string) ([]*Message, string, bool, error) + // If afterTime is non-nil, only messages created strictly after it are returned — + // used to hide history a participant has cleared from their own view. + ListByConversation(ctx context.Context, conversationID uuid.UUID, pageSize int, beforeCursor string, afterTime *time.Time) ([]*Message, string, bool, error) // UpdateBody persists body changes after an edit. UpdateBody(ctx context.Context, msg *Message) error @@ -66,6 +73,21 @@ type MessageRepository interface { GetLastMessages(ctx context.Context, conversationIDs []uuid.UUID) (map[uuid.UUID]*Message, error) } +// AttachmentRepository handles persistence for chat attachments. +type AttachmentRepository interface { + // Create inserts a new attachment. + Create(ctx context.Context, a *Attachment) error + + // GetByIDs returns attachments matching the given IDs (order not guaranteed). + GetByIDs(ctx context.Context, ids []uuid.UUID) ([]*Attachment, error) + + // LinkToMessage sets message_id for the given attachment IDs. + LinkToMessage(ctx context.Context, messageID uuid.UUID, attachmentIDs []uuid.UUID) error + + // ListByMessageIDs returns, for each message ID, its attachments (one query, not N+1). + ListByMessageIDs(ctx context.Context, messageIDs []uuid.UUID) (map[uuid.UUID][]*Attachment, error) +} + // ReadReceiptRepository handles read receipts. type ReadReceiptRepository interface { // Upsert inserts or ignores a read receipt (idempotent). diff --git a/services/iam/internal/infrastructure/postgres/chat_attachment_repository.go b/services/iam/internal/infrastructure/postgres/chat_attachment_repository.go new file mode 100644 index 00000000..c09f5fa1 --- /dev/null +++ b/services/iam/internal/infrastructure/postgres/chat_attachment_repository.go @@ -0,0 +1,166 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/mutugading/goapps-backend/services/iam/internal/domain/chat" +) + +// ChatAttachmentRepository implements chat.AttachmentRepository. +type ChatAttachmentRepository struct { + db *DB +} + +// NewChatAttachmentRepository constructs the repo. +func NewChatAttachmentRepository(db *DB) *ChatAttachmentRepository { + return &ChatAttachmentRepository{db: db} +} + +// Create inserts a new attachment. +func (r *ChatAttachmentRepository) Create(ctx context.Context, a *chat.Attachment) error { + const q = ` + INSERT INTO chat_attachment + (attachment_id, conversation_id, message_id, uploader_user_id, + file_name, file_url, content_type, file_size, thumbnail_url, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)` + if _, err := r.db.ExecContext(ctx, q, + a.AttachmentID(), a.ConversationID(), a.MessageID(), a.UploaderUserID(), + a.FileName(), a.FileURL(), a.ContentType(), a.FileSize(), a.ThumbnailURL(), a.CreatedAt(), + ); err != nil { + return fmt.Errorf("chat attachment repo: create: %w", err) + } + return nil +} + +// GetByIDs returns attachments matching the given IDs. +func (r *ChatAttachmentRepository) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]*chat.Attachment, error) { + if len(ids) == 0 { + return nil, nil + } + placeholders := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + q := fmt.Sprintf(` + SELECT attachment_id, conversation_id, message_id, uploader_user_id, + file_name, file_url, content_type, file_size, thumbnail_url, created_at + FROM chat_attachment + WHERE attachment_id IN (%s)`, strings.Join(placeholders, ",")) + + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("chat attachment repo: get by ids: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat attachment repo: close get-by-ids rows") + } + }() + + var attachments []*chat.Attachment + for rows.Next() { + att, scanErr := scanAttachment(rows) + if scanErr != nil { + return nil, fmt.Errorf("chat attachment repo: scan: %w", scanErr) + } + attachments = append(attachments, att) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat attachment repo: get by ids rows err: %w", err) + } + return attachments, nil +} + +// LinkToMessage sets message_id for the given attachment IDs. +func (r *ChatAttachmentRepository) LinkToMessage(ctx context.Context, messageID uuid.UUID, attachmentIDs []uuid.UUID) error { + if len(attachmentIDs) == 0 { + return nil + } + placeholders := make([]string, len(attachmentIDs)) + args := make([]any, 0, len(attachmentIDs)+1) + args = append(args, messageID) + for i, id := range attachmentIDs { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args = append(args, id) + } + q := fmt.Sprintf( + `UPDATE chat_attachment SET message_id=$1 WHERE attachment_id IN (%s)`, + strings.Join(placeholders, ","), + ) + if _, err := r.db.ExecContext(ctx, q, args...); err != nil { + return fmt.Errorf("chat attachment repo: link to message: %w", err) + } + return nil +} + +// ListByMessageIDs returns, for each message ID, its attachments. +func (r *ChatAttachmentRepository) ListByMessageIDs(ctx context.Context, messageIDs []uuid.UUID) (map[uuid.UUID][]*chat.Attachment, error) { + if len(messageIDs) == 0 { + return map[uuid.UUID][]*chat.Attachment{}, nil + } + placeholders := make([]string, len(messageIDs)) + args := make([]any, len(messageIDs)) + for i, id := range messageIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + q := fmt.Sprintf(` + SELECT attachment_id, conversation_id, message_id, uploader_user_id, + file_name, file_url, content_type, file_size, thumbnail_url, created_at + FROM chat_attachment + WHERE message_id IN (%s) + ORDER BY created_at ASC`, strings.Join(placeholders, ",")) + + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("chat attachment repo: list by message ids: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat attachment repo: close list-by-message rows") + } + }() + + result := make(map[uuid.UUID][]*chat.Attachment, len(messageIDs)) + for rows.Next() { + att, scanErr := scanAttachment(rows) + if scanErr != nil { + return nil, fmt.Errorf("chat attachment repo: scan: %w", scanErr) + } + if att.MessageID() != nil { + result[*att.MessageID()] = append(result[*att.MessageID()], att) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("chat attachment repo: list by message ids rows err: %w", err) + } + return result, nil +} + +func scanAttachment(s scanner) (*chat.Attachment, error) { + var ( + attachmentID, convID, uploaderID uuid.UUID + messageID *uuid.UUID + fileName, fileURL, contentType string + thumbnailURL sql.NullString + fileSize int64 + createdAt time.Time + ) + if err := s.Scan(&attachmentID, &convID, &messageID, &uploaderID, + &fileName, &fileURL, &contentType, &fileSize, &thumbnailURL, &createdAt); err != nil { + return nil, err + } + return chat.ReconstructAttachment( + attachmentID, convID, messageID, uploaderID, + fileName, fileURL, contentType, fileSize, thumbnailURL.String, createdAt, + ), nil +} diff --git a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go index 72feb1e8..6e523344 100644 --- a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go @@ -257,9 +257,18 @@ func (r *ChatConversationRepository) GetUnreadCounts(ctx context.Context, convID return counts, nil } +// ClearHistory sets history_cleared_at = NOW() for userID's participant row. +func (r *ChatConversationRepository) ClearHistory(ctx context.Context, convID, userID uuid.UUID) error { + const q = `UPDATE chat_participant SET history_cleared_at = NOW() WHERE conversation_id=$1 AND user_id=$2` + if _, err := r.db.ExecContext(ctx, q, convID, userID); err != nil { + return fmt.Errorf("chat conv repo: clear history: %w", err) + } + return nil +} + func (r *ChatConversationRepository) loadParticipants(ctx context.Context, convID uuid.UUID) ([]*chat.Participant, error) { const q = ` - SELECT user_id, role, joined_at, left_at, last_read_at + SELECT user_id, role, joined_at, left_at, last_read_at, history_cleared_at FROM chat_participant WHERE conversation_id = $1` rows, err := r.db.QueryContext(ctx, q, convID) @@ -271,19 +280,19 @@ func (r *ChatConversationRepository) loadParticipants(ctx context.Context, convI var parts []*chat.Participant for rows.Next() { var ( - userID uuid.UUID - roleStr string - joinedAt time.Time - leftAt, lastReadAt *time.Time + userID uuid.UUID + roleStr string + joinedAt time.Time + leftAt, lastReadAt, clearedAt *time.Time ) - if err := rows.Scan(&userID, &roleStr, &joinedAt, &leftAt, &lastReadAt); err != nil { + if err := rows.Scan(&userID, &roleStr, &joinedAt, &leftAt, &lastReadAt, &clearedAt); err != nil { return nil, fmt.Errorf("chat conv repo: scan participant: %w", err) } role, err := chat.ParseRole(roleStr) if err != nil { return nil, fmt.Errorf("chat conv repo: parse role: %w", err) } - parts = append(parts, chat.ReconstructParticipant(convID, userID, role, joinedAt, leftAt, lastReadAt)) + parts = append(parts, chat.ReconstructParticipant(convID, userID, role, joinedAt, leftAt, lastReadAt, clearedAt)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("chat conv repo: participants rows err: %w", err) diff --git a/services/iam/internal/infrastructure/postgres/chat_message_repository.go b/services/iam/internal/infrastructure/postgres/chat_message_repository.go index a886b8c4..64072f22 100644 --- a/services/iam/internal/infrastructure/postgres/chat_message_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_message_repository.go @@ -77,37 +77,53 @@ func (r *ChatMessageRepository) GetByID(ctx context.Context, id uuid.UUID) (*cha } // ListByConversation returns messages using cursor-based pagination (newest first). -func (r *ChatMessageRepository) ListByConversation(ctx context.Context, convID uuid.UUID, pageSize int, beforeCursor string) ([]*chat.Message, string, bool, error) { +// If afterTime is non-nil, only messages created strictly after it are included — +// used to hide history the caller has cleared from their own view. +func (r *ChatMessageRepository) ListByConversation(ctx context.Context, convID uuid.UUID, pageSize int, beforeCursor string, afterTime *time.Time) ([]*chat.Message, string, bool, error) { fetchSize := pageSize + 1 var rows *sql.Rows var err error if beforeCursor == "" { - const q = ` + const qBase = ` SELECT message_id, conversation_id, sender_user_id, body_encrypted, body_plain_encrypted, is_edited, is_deleted, reply_to_id, created_at, updated_at FROM chat_message - WHERE conversation_id = $1 AND is_deleted = FALSE + WHERE conversation_id = $1 AND is_deleted = FALSE` + if afterTime != nil { + rows, err = r.db.QueryContext(ctx, qBase+` + AND created_at > $2 + ORDER BY created_at DESC, message_id DESC + LIMIT $3`, convID, *afterTime, fetchSize) + } else { + rows, err = r.db.QueryContext(ctx, qBase+` ORDER BY created_at DESC, message_id DESC - LIMIT $2` - rows, err = r.db.QueryContext(ctx, q, convID, fetchSize) + LIMIT $2`, convID, fetchSize) + } } else { cursorTime, cursorID, parseErr := decodeChatCursor(beforeCursor) if parseErr != nil { return nil, "", false, fmt.Errorf("chat msg repo: invalid cursor: %w", parseErr) } - const q = ` + const qBase = ` SELECT message_id, conversation_id, sender_user_id, body_encrypted, body_plain_encrypted, is_edited, is_deleted, reply_to_id, created_at, updated_at FROM chat_message WHERE conversation_id = $1 AND is_deleted = FALSE - AND (created_at, message_id) < ($2, $3) + AND (created_at, message_id) < ($2, $3)` + if afterTime != nil { + rows, err = r.db.QueryContext(ctx, qBase+` + AND created_at > $4 ORDER BY created_at DESC, message_id DESC - LIMIT $4` - rows, err = r.db.QueryContext(ctx, q, convID, cursorTime, cursorID, fetchSize) + LIMIT $5`, convID, cursorTime, cursorID, *afterTime, fetchSize) + } else { + rows, err = r.db.QueryContext(ctx, qBase+` + ORDER BY created_at DESC, message_id DESC + LIMIT $4`, convID, cursorTime, cursorID, fetchSize) + } } if err != nil { return nil, "", false, fmt.Errorf("chat msg repo: list: %w", err) diff --git a/services/iam/internal/infrastructure/storage/storage.go b/services/iam/internal/infrastructure/storage/storage.go index 7c9410bf..f4bf05f9 100644 --- a/services/iam/internal/infrastructure/storage/storage.go +++ b/services/iam/internal/infrastructure/storage/storage.go @@ -22,6 +22,8 @@ type Service interface { UploadProfilePicture(ctx context.Context, userID string, filename string, data io.Reader, size int64, contentType string) (string, error) // UploadCMSImage uploads a CMS image and returns the object URL. UploadCMSImage(ctx context.Context, folder string, filename string, data io.Reader, size int64, contentType string) (string, error) + // UploadChatAttachment uploads a chat attachment and returns the object URL. + UploadChatAttachment(ctx context.Context, convID string, filename string, data io.Reader, size int64, contentType string) (string, error) // DeleteObject deletes a file by its object key. DeleteObject(ctx context.Context, objectKey string) error // ExtractObjectKey extracts the object key from a full URL. @@ -194,6 +196,36 @@ func (s *MinIOService) UploadCMSImage(ctx context.Context, folder string, filena return objectURL, nil } +// UploadChatAttachment uploads a chat attachment to MinIO. +// Files are stored as: {basePath}/chat/attachments/{convID}/{uuid}{ext} +// Example with basePath="iam": iam/chat/attachments/conv-id/uuid.pdf +func (s *MinIOService) UploadChatAttachment(ctx context.Context, convID string, filename string, data io.Reader, size int64, contentType string) (string, error) { + ext := path.Ext(filename) + objectName := fmt.Sprintf("%s/chat/attachments/%s/%s%s", s.basePath, convID, uuid.New().String(), ext) + + _, err := s.client.PutObject(ctx, s.bucket, objectName, data, size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", fmt.Errorf("failed to upload chat attachment: %w", err) + } + + var objectURL string + if s.publicURL != "" { + objectURL = fmt.Sprintf("%s/%s/%s", strings.TrimRight(s.publicURL, "/"), s.bucket, objectName) + } else { + objectURL = fmt.Sprintf("%s/%s/%s", s.client.EndpointURL().String(), s.bucket, objectName) + } + + log.Debug(). + Str("conv_id", convID). + Str("object", objectName). + Str("url", objectURL). + Msg("chat attachment uploaded") + + return objectURL, nil +} + // DeleteObject deletes a file from MinIO by its object key. func (s *MinIOService) DeleteObject(ctx context.Context, objectKey string) error { if objectKey == "" { diff --git a/services/iam/migrations/postgres/000077_add_history_cleared_at.down.sql b/services/iam/migrations/postgres/000077_add_history_cleared_at.down.sql new file mode 100644 index 00000000..a97f03ad --- /dev/null +++ b/services/iam/migrations/postgres/000077_add_history_cleared_at.down.sql @@ -0,0 +1,2 @@ +-- 000077: Rollback history_cleared_at from chat_participant. +ALTER TABLE chat_participant DROP COLUMN IF EXISTS history_cleared_at; diff --git a/services/iam/migrations/postgres/000077_add_history_cleared_at.up.sql b/services/iam/migrations/postgres/000077_add_history_cleared_at.up.sql new file mode 100644 index 00000000..0cab2d76 --- /dev/null +++ b/services/iam/migrations/postgres/000077_add_history_cleared_at.up.sql @@ -0,0 +1,2 @@ +-- 000077: Add history_cleared_at to chat_participant for per-user "Clear History". +ALTER TABLE chat_participant ADD COLUMN IF NOT EXISTS history_cleared_at TIMESTAMPTZ; diff --git a/services/iam/migrations/postgres/000078_create_chat_attachment.down.sql b/services/iam/migrations/postgres/000078_create_chat_attachment.down.sql new file mode 100644 index 00000000..e960f77f --- /dev/null +++ b/services/iam/migrations/postgres/000078_create_chat_attachment.down.sql @@ -0,0 +1,4 @@ +-- 000078 down: Drop chat_attachment table and its indexes. +DROP INDEX IF EXISTS idx_chat_attachment_conv; +DROP INDEX IF EXISTS idx_chat_attachment_message; +DROP TABLE IF EXISTS chat_attachment; diff --git a/services/iam/migrations/postgres/000078_create_chat_attachment.up.sql b/services/iam/migrations/postgres/000078_create_chat_attachment.up.sql new file mode 100644 index 00000000..26d4ebb1 --- /dev/null +++ b/services/iam/migrations/postgres/000078_create_chat_attachment.up.sql @@ -0,0 +1,16 @@ +-- 000078: Create chat_attachment table for file/image attachments on chat messages. +CREATE TABLE IF NOT EXISTS chat_attachment ( + attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES chat_conversation(conversation_id), + message_id UUID REFERENCES chat_message(message_id), + uploader_user_id UUID NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_url VARCHAR(500) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + thumbnail_url VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_chat_attachment_message ON chat_attachment(message_id); +CREATE INDEX IF NOT EXISTS idx_chat_attachment_conv ON chat_attachment(conversation_id); From 10a878564b3a5210c3be7885d990b1b1e7a4dc79 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 16:36:02 +0700 Subject: [PATCH 16/17] fix(iam): hide cleared history from conversation preview and unread count GetLastMessages and GetUnreadCounts ignored chat_participant.history_cleared_at, so after a user cleared a conversation the list preview still showed the old last message and its unread badge, even though the opened thread was correctly empty. - GetLastMessages now takes viewerID and joins the viewer's participant row, excluding messages created at or before their history_cleared_at. - GetUnreadCounts applies the same history_cleared_at boundary. Authored-By: Ilham R Co-Authored-By: Ilham R Co-Authored-By: Claude Opus 4.8 Co-Authored-By: IT Mutugading Co-Authored-By: Indra Putro Co-Authored-By: Ilham R Co-Authored-By: IT Mutu Gading --- .../chat/list_conversations_handler.go | 2 +- .../iam/internal/domain/chat/repository.go | 9 ++++-- .../postgres/chat_conversation_repository.go | 1 + .../postgres/chat_message_repository.go | 28 ++++++++++++------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/services/iam/internal/application/chat/list_conversations_handler.go b/services/iam/internal/application/chat/list_conversations_handler.go index ac096708..7b7ab365 100644 --- a/services/iam/internal/application/chat/list_conversations_handler.go +++ b/services/iam/internal/application/chat/list_conversations_handler.go @@ -51,7 +51,7 @@ func (h *ListConversationsHandler) Handle(ctx context.Context, callerID uuid.UUI convIDs[i] = c.ID() } - lastMsgs, err := h.msgRepo.GetLastMessages(ctx, convIDs) + lastMsgs, err := h.msgRepo.GetLastMessages(ctx, convIDs, callerID) if err != nil { return nil, fmt.Errorf("list conversations: last messages: %w", err) } diff --git a/services/iam/internal/domain/chat/repository.go b/services/iam/internal/domain/chat/repository.go index 079c5210..188710ac 100644 --- a/services/iam/internal/domain/chat/repository.go +++ b/services/iam/internal/domain/chat/repository.go @@ -68,9 +68,12 @@ type MessageRepository interface { // GetEditHistory returns all edit history for a message, newest first. GetEditHistory(ctx context.Context, messageID uuid.UUID) ([]*EditHistoryEntry, error) - // GetLastMessages returns, for each conversation ID, its most recent - // non-deleted message (one query, not N+1). - GetLastMessages(ctx context.Context, conversationIDs []uuid.UUID) (map[uuid.UUID]*Message, error) + // GetLastMessages returns, for each conversation ID, viewerID's most recent + // visible non-deleted message (one query, not N+1). Messages the viewer has + // cleared from their own history (created at or before their + // history_cleared_at) are excluded so the conversation preview matches the + // thread view. + GetLastMessages(ctx context.Context, conversationIDs []uuid.UUID, viewerID uuid.UUID) (map[uuid.UUID]*Message, error) } // AttachmentRepository handles persistence for chat attachments. diff --git a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go index 6e523344..b0a778d2 100644 --- a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go @@ -228,6 +228,7 @@ func (r *ChatConversationRepository) GetUnreadCounts(ctx context.Context, convID AND cm.is_deleted = FALSE AND cm.sender_user_id != $%d AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) + AND (cp.history_cleared_at IS NULL OR cm.created_at > cp.history_cleared_at) GROUP BY cm.conversation_id`, userParam, strings.Join(placeholders, ","), userParam) rows, err := r.db.QueryContext(ctx, q, args...) diff --git a/services/iam/internal/infrastructure/postgres/chat_message_repository.go b/services/iam/internal/infrastructure/postgres/chat_message_repository.go index 64072f22..5ad93934 100644 --- a/services/iam/internal/infrastructure/postgres/chat_message_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_message_repository.go @@ -282,25 +282,33 @@ func (r *ChatMessageRepository) GetEditHistory(ctx context.Context, messageID uu // GetLastMessages returns, for each conversation ID, its most recent // non-deleted message. Single query via DISTINCT ON — avoids N+1. -func (r *ChatMessageRepository) GetLastMessages(ctx context.Context, convIDs []uuid.UUID) (map[uuid.UUID]*chat.Message, error) { +func (r *ChatMessageRepository) GetLastMessages(ctx context.Context, convIDs []uuid.UUID, viewerID uuid.UUID) (map[uuid.UUID]*chat.Message, error) { if len(convIDs) == 0 { return map[uuid.UUID]*chat.Message{}, nil } placeholders := make([]string, len(convIDs)) - args := make([]any, len(convIDs)) + args := make([]any, 0, len(convIDs)+1) for i, id := range convIDs { placeholders[i] = fmt.Sprintf("$%d", i+1) - args[i] = id + args = append(args, id) } + viewerParam := len(convIDs) + 1 + args = append(args, viewerID) + // Join the viewer's participant row so a cleared history hides messages + // created at or before their history_cleared_at, matching the thread view. q := fmt.Sprintf(` - SELECT DISTINCT ON (conversation_id) - message_id, conversation_id, sender_user_id, - body_encrypted, body_plain_encrypted, - is_edited, is_deleted, reply_to_id, created_at, updated_at - FROM chat_message - WHERE conversation_id IN (%s) AND is_deleted = FALSE - ORDER BY conversation_id, created_at DESC, message_id DESC`, strings.Join(placeholders, ",")) + SELECT DISTINCT ON (cm.conversation_id) + cm.message_id, cm.conversation_id, cm.sender_user_id, + cm.body_encrypted, cm.body_plain_encrypted, + cm.is_edited, cm.is_deleted, cm.reply_to_id, cm.created_at, cm.updated_at + FROM chat_message cm + JOIN chat_participant cp + ON cp.conversation_id = cm.conversation_id AND cp.user_id = $%d + WHERE cm.conversation_id IN (%s) AND cm.is_deleted = FALSE + AND (cp.history_cleared_at IS NULL OR cm.created_at > cp.history_cleared_at) + ORDER BY cm.conversation_id, cm.created_at DESC, cm.message_id DESC`, + viewerParam, strings.Join(placeholders, ",")) rows, err := r.db.QueryContext(ctx, q, args...) if err != nil { From c49da07002e2861a5033fb91940dec9a8d848146 Mon Sep 17 00:00:00 2001 From: ilhom Date: Fri, 17 Jul 2026 00:38:31 +0700 Subject: [PATCH 17/17] fix(iam): resolve golangci-lint v2.3.0 findings in chat package CI lint (v2.3.0) caught issues the local v1.62 binary missed. Fix all 15: - errcheck: wrap defer rows.Close() in logging closures (conversation, message, user-resolver repos); nolint best-effort ResolveUsers name enrichment; nolint comma-ok map type assertions in the broadcaster. - errorlint: use errors.Is(err, redis.Nil) instead of == in the broadcaster. - gocognit/nestif: extract initChatServices from run(), and split the chat message page query into queryMessagePage/queryFirstPage/queryCursorPage. - nilnil: ResolveUsers returns an empty map (not nil,nil) on empty input. - revive package-comments: add package docs to application/chat and infrastructure/crypto. Verified locally with golangci-lint v2.3.0: 0 issues. Build, vet, and chat tests all pass. Authored-By: Ilham R Co-Authored-By: Ilham R Co-Authored-By: Claude Opus 4.8 Co-Authored-By: IT Mutugading Co-Authored-By: Indra Putro Co-Authored-By: Ilham R Co-Authored-By: IT Mutu Gading --- services/iam/cmd/server/main.go | 140 ++++++++++-------- .../chat/add_participants_handler.go | 2 + .../internal/delivery/grpc/chat_handler.go | 4 +- .../infrastructure/chat/broadcaster.go | 9 +- .../crypto/message_encryptor.go | 2 + .../postgres/chat_conversation_repository.go | 12 +- .../postgres/chat_message_repository.go | 115 ++++++++------ .../postgres/chat_user_resolver.go | 9 +- 8 files changed, 177 insertions(+), 116 deletions(-) diff --git a/services/iam/cmd/server/main.go b/services/iam/cmd/server/main.go index 898bec65..5ab54464 100644 --- a/services/iam/cmd/server/main.go +++ b/services/iam/cmd/server/main.go @@ -210,64 +210,14 @@ func run() error { ).WithRequestHandler(notifRequestHandler) // ── Chat + Presence ──────────────────────────────────────────────────── - chatMasterKeyHex := cfg.Chat.MasterKey - var chatHandler *grpcdelivery.ChatHandler - var presenceGRPCHandler *grpcdelivery.PresenceHandler - if chatMasterKeyHex != "" { - chatMasterKey, hexErr := hex.DecodeString(chatMasterKeyHex) - if hexErr != nil || len(chatMasterKey) != 32 { - log.Fatal().Msg("CHAT_MASTER_KEY must be a 64-char hex string (32 bytes)") - } - chatEnc, encErr := iamcrypto.NewEncryptor(chatMasterKey) - if encErr != nil { - log.Fatal().Err(encErr).Msg("Failed to init chat encryptor") - } - var chatBroadcaster *chatinfra.Broadcaster - if redisClient != nil { - chatBroadcaster = chatinfra.NewRedisBroadcaster(redisClient.Client) - } else { - chatBroadcaster = chatinfra.NewBroadcaster() - } - var presenceSvc *chatinfra.PresenceService - if redisClient != nil { - presenceSvc = chatinfra.NewPresenceService(redisClient.Client) - } - chatConvRepo := postgres.NewChatConversationRepository(db) - chatMsgRepo := postgres.NewChatMessageRepository(db) - chatReceiptRepo := postgres.NewChatReadReceiptRepository(db) - chatAttRepo := postgres.NewChatAttachmentRepository(db) - chatUserResolver := postgres.NewChatUserResolver(db) - sendMsgHandler := appChat.NewSendMessageHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatEnc, chatBroadcaster) - sendMsgHandler.WithAttachments(chatAttRepo) - if presenceSvc != nil && redisClient != nil { - sendMsgHandler.WithOfflineNotification(presenceSvc, notifCreate, notifEmailDispatcher, redisClient.Client, chatUserResolver) - } - chatHandler = grpcdelivery.NewChatHandler( - appChat.NewCreateDirectHandler(chatConvRepo, chatEnc), - appChat.NewCreateGroupHandler(chatConvRepo, chatEnc), - appChat.NewGetConversationHandler(chatConvRepo), - appChat.NewListConversationsHandler(chatConvRepo, chatMsgRepo, chatEnc), - appChat.NewLeaveConversationHandler(chatConvRepo), - sendMsgHandler, - appChat.NewEditMessageHandler(chatConvRepo, chatMsgRepo, chatEnc, chatBroadcaster, chatUserResolver), - appChat.NewDeleteMessageHandler(chatConvRepo, chatMsgRepo, chatBroadcaster), - appChat.NewListMessagesHandler(chatConvRepo, chatMsgRepo, chatEnc), - appChat.NewMarkReadHandler(chatConvRepo, chatMsgRepo, chatReceiptRepo, chatBroadcaster), - appChat.NewSetTypingHandler(chatConvRepo, presenceSvc, chatBroadcaster), - appChat.NewStreamHandler(chatBroadcaster), - appChat.NewGetEditHistoryHandler(chatConvRepo, chatMsgRepo, chatEnc), - appChat.NewClearHistoryHandler(chatConvRepo), - appChat.NewUploadAttachmentHandler(chatConvRepo, chatAttRepo, storageSvc), - chatAttRepo, - chatUserResolver, - ) - if presenceSvc != nil { - presenceGRPCHandler = grpcdelivery.NewPresenceHandler(presenceSvc) - } - log.Info().Msg("Chat + Presence services initialized") - } else { - log.Warn().Msg("CHAT_MASTER_KEY not set — chat services disabled") - } + chatHandler, presenceGRPCHandler := initChatServices(chatDeps{ + masterKeyHex: cfg.Chat.MasterKey, + db: db, + redisClient: redisClient, + notifCreate: notifCreate, + emailDispatcher: notifEmailDispatcher, + storageSvc: storageSvc, + }) // Setup gRPC server with interceptor chain (pass JWT + session cache + session repo for auth & activity tracking) grpcServer, err := grpcdelivery.NewServer(&cfg.Server, db, jwtService, sessionCache, sessionRepo, cfg.Security.InternalServiceToken) @@ -344,6 +294,80 @@ func run() error { return nil } +// chatDeps bundles the dependencies needed to wire the chat + presence stack. +type chatDeps struct { + masterKeyHex string + db *postgres.DB + redisClient *redisinfra.Client + notifCreate *appnotif.CreateHandler + emailDispatcher appnotif.EmailDispatcher + storageSvc storageinfra.Service +} + +// initChatServices builds the chat and presence gRPC handlers. Both are nil +// when CHAT_MASTER_KEY is unset (chat disabled); presence is nil when Redis is +// unavailable. Extracted from run to keep its cognitive complexity in bounds. +func initChatServices(d chatDeps) (*grpcdelivery.ChatHandler, *grpcdelivery.PresenceHandler) { + if d.masterKeyHex == "" { + log.Warn().Msg("CHAT_MASTER_KEY not set — chat services disabled") + return nil, nil + } + chatMasterKey, hexErr := hex.DecodeString(d.masterKeyHex) + if hexErr != nil || len(chatMasterKey) != 32 { + log.Fatal().Msg("CHAT_MASTER_KEY must be a 64-char hex string (32 bytes)") + } + chatEnc, encErr := iamcrypto.NewEncryptor(chatMasterKey) + if encErr != nil { + log.Fatal().Err(encErr).Msg("Failed to init chat encryptor") + } + + chatBroadcaster := chatinfra.NewBroadcaster() + var presenceSvc *chatinfra.PresenceService + if d.redisClient != nil { + chatBroadcaster = chatinfra.NewRedisBroadcaster(d.redisClient.Client) + presenceSvc = chatinfra.NewPresenceService(d.redisClient.Client) + } + + convRepo := postgres.NewChatConversationRepository(d.db) + msgRepo := postgres.NewChatMessageRepository(d.db) + receiptRepo := postgres.NewChatReadReceiptRepository(d.db) + attRepo := postgres.NewChatAttachmentRepository(d.db) + userResolver := postgres.NewChatUserResolver(d.db) + + sendMsgHandler := appChat.NewSendMessageHandler(convRepo, msgRepo, receiptRepo, chatEnc, chatBroadcaster) + sendMsgHandler.WithAttachments(attRepo) + if presenceSvc != nil && d.redisClient != nil { + sendMsgHandler.WithOfflineNotification(presenceSvc, d.notifCreate, d.emailDispatcher, d.redisClient.Client, userResolver) + } + + chatHandler := grpcdelivery.NewChatHandler( + appChat.NewCreateDirectHandler(convRepo, chatEnc), + appChat.NewCreateGroupHandler(convRepo, chatEnc), + appChat.NewGetConversationHandler(convRepo), + appChat.NewListConversationsHandler(convRepo, msgRepo, chatEnc), + appChat.NewLeaveConversationHandler(convRepo), + sendMsgHandler, + appChat.NewEditMessageHandler(convRepo, msgRepo, chatEnc, chatBroadcaster, userResolver), + appChat.NewDeleteMessageHandler(convRepo, msgRepo, chatBroadcaster), + appChat.NewListMessagesHandler(convRepo, msgRepo, chatEnc), + appChat.NewMarkReadHandler(convRepo, msgRepo, receiptRepo, chatBroadcaster), + appChat.NewSetTypingHandler(convRepo, presenceSvc, chatBroadcaster), + appChat.NewStreamHandler(chatBroadcaster), + appChat.NewGetEditHistoryHandler(convRepo, msgRepo, chatEnc), + appChat.NewClearHistoryHandler(convRepo), + appChat.NewUploadAttachmentHandler(convRepo, attRepo, d.storageSvc), + attRepo, + userResolver, + ) + + var presenceGRPCHandler *grpcdelivery.PresenceHandler + if presenceSvc != nil { + presenceGRPCHandler = grpcdelivery.NewPresenceHandler(presenceSvc) + } + log.Info().Msg("Chat + Presence services initialized") + return chatHandler, presenceGRPCHandler +} + // setupLogger configures the application logger. func setupLogger() { zerolog.TimeFieldFormat = time.RFC3339 diff --git a/services/iam/internal/application/chat/add_participants_handler.go b/services/iam/internal/application/chat/add_participants_handler.go index 8b101b0f..9b347c24 100644 --- a/services/iam/internal/application/chat/add_participants_handler.go +++ b/services/iam/internal/application/chat/add_participants_handler.go @@ -1,3 +1,5 @@ +// Package chat implements the application-layer use cases for chat +// conversations, messages, attachments, presence, and real-time events. package chat import ( diff --git a/services/iam/internal/delivery/grpc/chat_handler.go b/services/iam/internal/delivery/grpc/chat_handler.go index 29b0dc31..83be0d90 100644 --- a/services/iam/internal/delivery/grpc/chat_handler.go +++ b/services/iam/internal/delivery/grpc/chat_handler.go @@ -272,7 +272,7 @@ func (h *ChatHandler) ListMessages(ctx context.Context, req *iamv1.ListMessagesR for _, dm := range result.Messages { senderIDs = append(senderIDs, dm.SenderUserID()) } - senderMap, _ := h.userResolver.ResolveUsers(ctx, senderIDs) + senderMap, _ := h.userResolver.ResolveUsers(ctx, senderIDs) //nolint:errcheck // name enrichment is best-effort msgIDs := make([]uuid.UUID, 0, len(result.Messages)) for _, dm := range result.Messages { @@ -447,7 +447,7 @@ func (h *ChatHandler) convToProto(ctx context.Context, conv *chat.Conversation) for _, p := range conv.Participants() { userIDs = append(userIDs, p.UserID()) } - userMap, _ := h.userResolver.ResolveUsers(ctx, userIDs) + userMap, _ := h.userResolver.ResolveUsers(ctx, userIDs) //nolint:errcheck // name enrichment is best-effort parts := make([]*iamv1.ParticipantProto, 0, len(conv.Participants())) for _, p := range conv.Participants() { diff --git a/services/iam/internal/infrastructure/chat/broadcaster.go b/services/iam/internal/infrastructure/chat/broadcaster.go index 24765b6a..ee5c2508 100644 --- a/services/iam/internal/infrastructure/chat/broadcaster.go +++ b/services/iam/internal/infrastructure/chat/broadcaster.go @@ -15,6 +15,7 @@ package chat import ( "context" "encoding/base64" + "errors" "fmt" "os" "sync" @@ -200,7 +201,7 @@ func (b *Broadcaster) bridgeRedisStream(recipient uuid.UUID, ch chan *Event) con Count: 50, }).Result() if err != nil { - if err == redis.Nil || ctx.Err() != nil { + if errors.Is(err, redis.Nil) || ctx.Err() != nil { continue } log.Warn().Err(err).Msg("broadcaster: XREAD error") @@ -220,12 +221,12 @@ func (b *Broadcaster) bridgeRedisStream(recipient uuid.UUID, ch chan *Event) con } func (b *Broadcaster) handleStreamMessage(msg redis.XMessage, userID uuid.UUID, ch chan *Event) { - podID, _ := msg.Values["pod"].(string) + podID, _ := msg.Values["pod"].(string) //nolint:errcheck // absent/typed-wrong pod is treated as empty if podID == b.selfPodID { return } - eventID, _ := msg.Values["eid"].(string) - dataB64, _ := msg.Values["data"].(string) + eventID, _ := msg.Values["eid"].(string) //nolint:errcheck // absent eid is treated as empty + dataB64, _ := msg.Values["data"].(string) //nolint:errcheck // absent data fails decode below protoBytes, err := base64.StdEncoding.DecodeString(dataB64) if err != nil { log.Warn().Err(err).Msg("broadcaster: decode stream data") diff --git a/services/iam/internal/infrastructure/crypto/message_encryptor.go b/services/iam/internal/infrastructure/crypto/message_encryptor.go index 290b0542..08cabdcb 100644 --- a/services/iam/internal/infrastructure/crypto/message_encryptor.go +++ b/services/iam/internal/infrastructure/crypto/message_encryptor.go @@ -1,3 +1,5 @@ +// Package crypto provides AES-256-GCM encryption for chat conversation keys +// and message bodies, wrapping per-conversation keys with a master key. package crypto import ( diff --git a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go index b0a778d2..fce55d17 100644 --- a/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_conversation_repository.go @@ -119,7 +119,11 @@ func (r *ChatConversationRepository) ListByUserID(ctx context.Context, userID uu if err != nil { return nil, 0, fmt.Errorf("chat conv repo: list: %w", err) } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat conv repo: close rows") + } + }() var ids []uuid.UUID for rows.Next() { @@ -276,7 +280,11 @@ func (r *ChatConversationRepository) loadParticipants(ctx context.Context, convI if err != nil { return nil, fmt.Errorf("chat conv repo: load participants: %w", err) } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat conv repo: close rows") + } + }() var parts []*chat.Participant for rows.Next() { diff --git a/services/iam/internal/infrastructure/postgres/chat_message_repository.go b/services/iam/internal/infrastructure/postgres/chat_message_repository.go index 5ad93934..01253c23 100644 --- a/services/iam/internal/infrastructure/postgres/chat_message_repository.go +++ b/services/iam/internal/infrastructure/postgres/chat_message_repository.go @@ -82,53 +82,15 @@ func (r *ChatMessageRepository) GetByID(ctx context.Context, id uuid.UUID) (*cha func (r *ChatMessageRepository) ListByConversation(ctx context.Context, convID uuid.UUID, pageSize int, beforeCursor string, afterTime *time.Time) ([]*chat.Message, string, bool, error) { fetchSize := pageSize + 1 - var rows *sql.Rows - var err error - - if beforeCursor == "" { - const qBase = ` - SELECT message_id, conversation_id, sender_user_id, - body_encrypted, body_plain_encrypted, - is_edited, is_deleted, reply_to_id, created_at, updated_at - FROM chat_message - WHERE conversation_id = $1 AND is_deleted = FALSE` - if afterTime != nil { - rows, err = r.db.QueryContext(ctx, qBase+` - AND created_at > $2 - ORDER BY created_at DESC, message_id DESC - LIMIT $3`, convID, *afterTime, fetchSize) - } else { - rows, err = r.db.QueryContext(ctx, qBase+` - ORDER BY created_at DESC, message_id DESC - LIMIT $2`, convID, fetchSize) - } - } else { - cursorTime, cursorID, parseErr := decodeChatCursor(beforeCursor) - if parseErr != nil { - return nil, "", false, fmt.Errorf("chat msg repo: invalid cursor: %w", parseErr) - } - const qBase = ` - SELECT message_id, conversation_id, sender_user_id, - body_encrypted, body_plain_encrypted, - is_edited, is_deleted, reply_to_id, created_at, updated_at - FROM chat_message - WHERE conversation_id = $1 AND is_deleted = FALSE - AND (created_at, message_id) < ($2, $3)` - if afterTime != nil { - rows, err = r.db.QueryContext(ctx, qBase+` - AND created_at > $4 - ORDER BY created_at DESC, message_id DESC - LIMIT $5`, convID, cursorTime, cursorID, *afterTime, fetchSize) - } else { - rows, err = r.db.QueryContext(ctx, qBase+` - ORDER BY created_at DESC, message_id DESC - LIMIT $4`, convID, cursorTime, cursorID, fetchSize) - } - } + rows, err := r.queryMessagePage(ctx, convID, fetchSize, beforeCursor, afterTime) if err != nil { - return nil, "", false, fmt.Errorf("chat msg repo: list: %w", err) + return nil, "", false, err } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat msg repo: close rows") + } + }() var msgs []*chat.Message for rows.Next() { @@ -159,6 +121,51 @@ func (r *ChatMessageRepository) ListByConversation(ctx context.Context, convID u return msgs, nextCursor, hasMore, nil } +const chatMessageSelectCols = ` + SELECT message_id, conversation_id, sender_user_id, + body_encrypted, body_plain_encrypted, + is_edited, is_deleted, reply_to_id, created_at, updated_at + FROM chat_message + WHERE conversation_id = $1 AND is_deleted = FALSE` + +// queryMessagePage dispatches to the first-page or cursor-paginated query, +// each optionally bounded by afterTime (the caller's cleared-history cutoff). +func (r *ChatMessageRepository) queryMessagePage(ctx context.Context, convID uuid.UUID, fetchSize int, beforeCursor string, afterTime *time.Time) (*sql.Rows, error) { + if beforeCursor == "" { + return r.queryFirstPage(ctx, convID, fetchSize, afterTime) + } + cursorTime, cursorID, parseErr := decodeChatCursor(beforeCursor) + if parseErr != nil { + return nil, fmt.Errorf("chat msg repo: invalid cursor: %w", parseErr) + } + return r.queryCursorPage(ctx, convID, fetchSize, cursorTime, cursorID, afterTime) +} + +func (r *ChatMessageRepository) queryFirstPage(ctx context.Context, convID uuid.UUID, fetchSize int, afterTime *time.Time) (*sql.Rows, error) { + if afterTime != nil { + return r.db.QueryContext(ctx, chatMessageSelectCols+` + AND created_at > $2 + ORDER BY created_at DESC, message_id DESC + LIMIT $3`, convID, *afterTime, fetchSize) + } + return r.db.QueryContext(ctx, chatMessageSelectCols+` + ORDER BY created_at DESC, message_id DESC + LIMIT $2`, convID, fetchSize) +} + +func (r *ChatMessageRepository) queryCursorPage(ctx context.Context, convID uuid.UUID, fetchSize int, cursorTime time.Time, cursorID uuid.UUID, afterTime *time.Time) (*sql.Rows, error) { + const cursorFilter = ` AND (created_at, message_id) < ($2, $3)` + if afterTime != nil { + return r.db.QueryContext(ctx, chatMessageSelectCols+cursorFilter+` + AND created_at > $4 + ORDER BY created_at DESC, message_id DESC + LIMIT $5`, convID, cursorTime, cursorID, *afterTime, fetchSize) + } + return r.db.QueryContext(ctx, chatMessageSelectCols+cursorFilter+` + ORDER BY created_at DESC, message_id DESC + LIMIT $4`, convID, cursorTime, cursorID, fetchSize) +} + // attachReceipts batch-loads read receipts for all given messages (one query, // not N+1) and reconstructs each message with its receipts attached. func (r *ChatMessageRepository) attachReceipts(ctx context.Context, msgs []*chat.Message) error { @@ -259,7 +266,11 @@ func (r *ChatMessageRepository) GetEditHistory(ctx context.Context, messageID uu if err != nil { return nil, fmt.Errorf("chat msg repo: get edit history: %w", err) } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat msg repo: close rows") + } + }() var entries []*chat.EditHistoryEntry for rows.Next() { @@ -340,7 +351,11 @@ func (r *ChatMessageRepository) loadReceipts(ctx context.Context, msgID uuid.UUI if err != nil { return nil, fmt.Errorf("chat msg repo: load receipts: %w", err) } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat msg repo: close rows") + } + }() var receipts []*chat.ReadReceipt for rows.Next() { var ( @@ -424,7 +439,11 @@ func (r *ChatReadReceiptRepository) ListByMessage(ctx context.Context, msgID uui if err != nil { return nil, fmt.Errorf("chat receipt repo: list: %w", err) } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat msg repo: close rows") + } + }() var receipts []*chat.ReadReceipt for rows.Next() { var ( diff --git a/services/iam/internal/infrastructure/postgres/chat_user_resolver.go b/services/iam/internal/infrastructure/postgres/chat_user_resolver.go index 7353a5ac..9066180f 100644 --- a/services/iam/internal/infrastructure/postgres/chat_user_resolver.go +++ b/services/iam/internal/infrastructure/postgres/chat_user_resolver.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/rs/zerolog/log" ) // ChatUserInfo holds lightweight user data for chat participant display. @@ -30,7 +31,7 @@ func NewChatUserResolver(db *DB) *ChatUserResolver { // ResolveUsers batch-resolves user IDs to display info. func (r *ChatUserResolver) ResolveUsers(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]*ChatUserInfo, error) { if len(userIDs) == 0 { - return nil, nil + return map[uuid.UUID]*ChatUserInfo{}, nil } placeholders := make([]string, len(userIDs)) args := make([]any, len(userIDs)) @@ -50,7 +51,11 @@ func (r *ChatUserResolver) ResolveUsers(ctx context.Context, userIDs []uuid.UUID if err != nil { return nil, fmt.Errorf("chat user resolver: query: %w", err) } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("chat user resolver: close rows") + } + }() result := make(map[uuid.UUID]*ChatUserInfo, len(userIDs)) for rows.Next() {