refactor(inbox): store memo comment payloads without activity records (#5741)

Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
This commit is contained in:
memoclaw 2026-03-19 19:33:25 +08:00 committed by GitHub
parent a249d06e2e
commit f759b416af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 333 additions and 1039 deletions

View File

@ -1,180 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: store/activity.proto
package store
import (
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)
)
type ActivityMemoCommentPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
MemoId int32 `protobuf:"varint,1,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
RelatedMemoId int32 `protobuf:"varint,2,opt,name=related_memo_id,json=relatedMemoId,proto3" json:"related_memo_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivityMemoCommentPayload) Reset() {
*x = ActivityMemoCommentPayload{}
mi := &file_store_activity_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivityMemoCommentPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivityMemoCommentPayload) ProtoMessage() {}
func (x *ActivityMemoCommentPayload) ProtoReflect() protoreflect.Message {
mi := &file_store_activity_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 ActivityMemoCommentPayload.ProtoReflect.Descriptor instead.
func (*ActivityMemoCommentPayload) Descriptor() ([]byte, []int) {
return file_store_activity_proto_rawDescGZIP(), []int{0}
}
func (x *ActivityMemoCommentPayload) GetMemoId() int32 {
if x != nil {
return x.MemoId
}
return 0
}
func (x *ActivityMemoCommentPayload) GetRelatedMemoId() int32 {
if x != nil {
return x.RelatedMemoId
}
return 0
}
type ActivityPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
MemoComment *ActivityMemoCommentPayload `protobuf:"bytes,1,opt,name=memo_comment,json=memoComment,proto3" json:"memo_comment,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivityPayload) Reset() {
*x = ActivityPayload{}
mi := &file_store_activity_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivityPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivityPayload) ProtoMessage() {}
func (x *ActivityPayload) ProtoReflect() protoreflect.Message {
mi := &file_store_activity_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 ActivityPayload.ProtoReflect.Descriptor instead.
func (*ActivityPayload) Descriptor() ([]byte, []int) {
return file_store_activity_proto_rawDescGZIP(), []int{1}
}
func (x *ActivityPayload) GetMemoComment() *ActivityMemoCommentPayload {
if x != nil {
return x.MemoComment
}
return nil
}
var File_store_activity_proto protoreflect.FileDescriptor
const file_store_activity_proto_rawDesc = "" +
"\n" +
"\x14store/activity.proto\x12\vmemos.store\"]\n" +
"\x1aActivityMemoCommentPayload\x12\x17\n" +
"\amemo_id\x18\x01 \x01(\x05R\x06memoId\x12&\n" +
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\"]\n" +
"\x0fActivityPayload\x12J\n" +
"\fmemo_comment\x18\x01 \x01(\v2'.memos.store.ActivityMemoCommentPayloadR\vmemoCommentB\x98\x01\n" +
"\x0fcom.memos.storeB\rActivityProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3"
var (
file_store_activity_proto_rawDescOnce sync.Once
file_store_activity_proto_rawDescData []byte
)
func file_store_activity_proto_rawDescGZIP() []byte {
file_store_activity_proto_rawDescOnce.Do(func() {
file_store_activity_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_store_activity_proto_rawDesc), len(file_store_activity_proto_rawDesc)))
})
return file_store_activity_proto_rawDescData
}
var file_store_activity_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_store_activity_proto_goTypes = []any{
(*ActivityMemoCommentPayload)(nil), // 0: memos.store.ActivityMemoCommentPayload
(*ActivityPayload)(nil), // 1: memos.store.ActivityPayload
}
var file_store_activity_proto_depIdxs = []int32{
0, // 0: memos.store.ActivityPayload.memo_comment:type_name -> memos.store.ActivityMemoCommentPayload
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_store_activity_proto_init() }
func file_store_activity_proto_init() {
if File_store_activity_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_activity_proto_rawDesc), len(file_store_activity_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_store_activity_proto_goTypes,
DependencyIndexes: file_store_activity_proto_depIdxs,
MessageInfos: file_store_activity_proto_msgTypes,
}.Build()
File_store_activity_proto = out.File
file_store_activity_proto_goTypes = nil
file_store_activity_proto_depIdxs = nil
}

View File

@ -72,8 +72,10 @@ type InboxMessage struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The type of the inbox message.
Type InboxMessage_Type `protobuf:"varint,1,opt,name=type,proto3,enum=memos.store.InboxMessage_Type" json:"type,omitempty"`
// The system-generated unique ID of related activity.
ActivityId *int32 `protobuf:"varint,2,opt,name=activity_id,json=activityId,proto3,oneof" json:"activity_id,omitempty"`
// Types that are valid to be assigned to Payload:
//
// *InboxMessage_MemoComment
Payload isInboxMessage_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -115,9 +117,80 @@ func (x *InboxMessage) GetType() InboxMessage_Type {
return InboxMessage_TYPE_UNSPECIFIED
}
func (x *InboxMessage) GetActivityId() int32 {
if x != nil && x.ActivityId != nil {
return *x.ActivityId
func (x *InboxMessage) GetPayload() isInboxMessage_Payload {
if x != nil {
return x.Payload
}
return nil
}
func (x *InboxMessage) GetMemoComment() *InboxMessage_MemoCommentPayload {
if x != nil {
if x, ok := x.Payload.(*InboxMessage_MemoComment); ok {
return x.MemoComment
}
}
return nil
}
type isInboxMessage_Payload interface {
isInboxMessage_Payload()
}
type InboxMessage_MemoComment struct {
MemoComment *InboxMessage_MemoCommentPayload `protobuf:"bytes,2,opt,name=memo_comment,json=memoComment,proto3,oneof"`
}
func (*InboxMessage_MemoComment) isInboxMessage_Payload() {}
type InboxMessage_MemoCommentPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
MemoId int32 `protobuf:"varint,1,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
RelatedMemoId int32 `protobuf:"varint,2,opt,name=related_memo_id,json=relatedMemoId,proto3" json:"related_memo_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InboxMessage_MemoCommentPayload) Reset() {
*x = InboxMessage_MemoCommentPayload{}
mi := &file_store_inbox_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *InboxMessage_MemoCommentPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InboxMessage_MemoCommentPayload) ProtoMessage() {}
func (x *InboxMessage_MemoCommentPayload) ProtoReflect() protoreflect.Message {
mi := &file_store_inbox_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 InboxMessage_MemoCommentPayload.ProtoReflect.Descriptor instead.
func (*InboxMessage_MemoCommentPayload) Descriptor() ([]byte, []int) {
return file_store_inbox_proto_rawDescGZIP(), []int{0, 0}
}
func (x *InboxMessage_MemoCommentPayload) GetMemoId() int32 {
if x != nil {
return x.MemoId
}
return 0
}
func (x *InboxMessage_MemoCommentPayload) GetRelatedMemoId() int32 {
if x != nil {
return x.RelatedMemoId
}
return 0
}
@ -126,15 +199,17 @@ var File_store_inbox_proto protoreflect.FileDescriptor
const file_store_inbox_proto_rawDesc = "" +
"\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\xa8\x01\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\xa7\x02\n" +
"\fInboxMessage\x122\n" +
"\x04type\x18\x01 \x01(\x0e2\x1e.memos.store.InboxMessage.TypeR\x04type\x12$\n" +
"\vactivity_id\x18\x02 \x01(\x05H\x00R\n" +
"activityId\x88\x01\x01\".\n" +
"\x04type\x18\x01 \x01(\x0e2\x1e.memos.store.InboxMessage.TypeR\x04type\x12Q\n" +
"\fmemo_comment\x18\x02 \x01(\v2,.memos.store.InboxMessage.MemoCommentPayloadH\x00R\vmemoComment\x1aU\n" +
"\x12MemoCommentPayload\x12\x17\n" +
"\amemo_id\x18\x01 \x01(\x05R\x06memoId\x12&\n" +
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\".\n" +
"\x04Type\x12\x14\n" +
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
"\fMEMO_COMMENT\x10\x01B\x0e\n" +
"\f_activity_idB\x95\x01\n" +
"\fMEMO_COMMENT\x10\x01B\t\n" +
"\apayloadB\x95\x01\n" +
"\x0fcom.memos.storeB\n" +
"InboxProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3"
@ -151,18 +226,20 @@ func file_store_inbox_proto_rawDescGZIP() []byte {
}
var file_store_inbox_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_store_inbox_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_store_inbox_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_store_inbox_proto_goTypes = []any{
(InboxMessage_Type)(0), // 0: memos.store.InboxMessage.Type
(*InboxMessage)(nil), // 1: memos.store.InboxMessage
(InboxMessage_Type)(0), // 0: memos.store.InboxMessage.Type
(*InboxMessage)(nil), // 1: memos.store.InboxMessage
(*InboxMessage_MemoCommentPayload)(nil), // 2: memos.store.InboxMessage.MemoCommentPayload
}
var file_store_inbox_proto_depIdxs = []int32{
0, // 0: memos.store.InboxMessage.type:type_name -> memos.store.InboxMessage.Type
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
2, // 1: memos.store.InboxMessage.memo_comment:type_name -> memos.store.InboxMessage.MemoCommentPayload
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_store_inbox_proto_init() }
@ -170,14 +247,16 @@ func file_store_inbox_proto_init() {
if File_store_inbox_proto != nil {
return
}
file_store_inbox_proto_msgTypes[0].OneofWrappers = []any{}
file_store_inbox_proto_msgTypes[0].OneofWrappers = []any{
(*InboxMessage_MemoComment)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_inbox_proto_rawDesc), len(file_store_inbox_proto_rawDesc)),
NumEnums: 1,
NumMessages: 1,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -1,14 +0,0 @@
syntax = "proto3";
package memos.store;
option go_package = "gen/store";
message ActivityMemoCommentPayload {
int32 memo_id = 1;
int32 related_memo_id = 2;
}
message ActivityPayload {
ActivityMemoCommentPayload memo_comment = 1;
}

View File

@ -5,10 +5,16 @@ package memos.store;
option go_package = "gen/store";
message InboxMessage {
message MemoCommentPayload {
int32 memo_id = 1;
int32 related_memo_id = 2;
}
// The type of the inbox message.
Type type = 1;
// The system-generated unique ID of related activity.
optional int32 activity_id = 2;
oneof payload {
MemoCommentPayload memo_comment = 2;
}
enum Type {
TYPE_UNSPECIFIED = 0;

View File

@ -648,27 +648,18 @@ func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.Crea
return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator")
}
if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID {
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
CreatorID: creatorID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{
MemoComment: &storepb.ActivityMemoCommentPayload{
MemoId: memo.ID,
RelatedMemoId: relatedMemo.ID,
},
},
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create activity")
}
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
SenderID: creatorID,
ReceiverID: relatedMemo.CreatorID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_MEMO_COMMENT,
ActivityId: &activity.ID,
Type: storepb.InboxMessage_MEMO_COMMENT,
Payload: &storepb.InboxMessage_MemoComment{
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
MemoId: memo.ID,
RelatedMemoId: relatedMemo.ID,
},
},
},
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to create inbox")

View File

@ -55,7 +55,7 @@ func TestListUserNotificationsIncludesMemoCommentPayload(t *testing.T) {
require.Equal(t, memo.Name, notification.GetMemoComment().RelatedMemo)
}
func TestListUserNotificationsOmitsPayloadWhenActivityMissing(t *testing.T) {
func TestListUserNotificationsStoresMemoCommentPayloadInInbox(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
@ -93,17 +93,9 @@ func TestListUserNotificationsOmitsPayloadWhenActivityMissing(t *testing.T) {
require.NoError(t, err)
require.Len(t, inboxes, 1)
require.NotNil(t, inboxes[0].Message)
require.NotNil(t, inboxes[0].Message.ActivityId)
_, err = ts.Store.GetDriver().GetDB().ExecContext(ctx, "DELETE FROM activity WHERE id = ?", *inboxes[0].Message.ActivityId)
require.NoError(t, err)
resp, err := ts.Service.ListUserNotifications(ownerCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%d", owner.ID),
})
require.NoError(t, err)
require.Len(t, resp.Notifications, 1)
require.Nil(t, resp.Notifications[0].GetMemoComment())
require.NotNil(t, inboxes[0].Message.GetMemoComment())
require.NotZero(t, inboxes[0].Message.GetMemoComment().MemoId)
require.NotZero(t, inboxes[0].Message.GetMemoComment().RelatedMemoId)
}
func TestListUserNotificationsOmitsPayloadWhenMemosDeleted(t *testing.T) {

View File

@ -1414,7 +1414,7 @@ func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox
notification.Status = v1pb.UserNotification_STATUS_UNSPECIFIED
}
// Extract notification type and activity ID from inbox message
// Extract notification type and payload from the inbox message.
if inbox.Message != nil {
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
@ -1438,22 +1438,13 @@ func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox
}
func (s *APIV1Service) convertUserNotificationPayload(ctx context.Context, message *storepb.InboxMessage) (*v1pb.UserNotification_MemoCommentPayload, error) {
if message == nil || message.Type != storepb.InboxMessage_MEMO_COMMENT || message.ActivityId == nil {
return nil, nil
}
activity, err := s.Store.GetActivity(ctx, &store.FindActivity{
ID: message.ActivityId,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get activity")
}
if activity == nil || activity.Payload == nil || activity.Payload.MemoComment == nil {
memoComment := message.GetMemoComment()
if message == nil || message.Type != storepb.InboxMessage_MEMO_COMMENT || memoComment == nil {
return nil, nil
}
commentMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &activity.Payload.MemoComment.MemoId,
ID: &memoComment.MemoId,
ExcludeContent: true,
})
if err != nil {
@ -1464,7 +1455,7 @@ func (s *APIV1Service) convertUserNotificationPayload(ctx context.Context, messa
}
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &activity.Payload.MemoComment.RelatedMemoId,
ID: &memoComment.RelatedMemoId,
ExcludeContent: true,
})
if err != nil {

View File

@ -1,68 +0,0 @@
package store
import (
"context"
storepb "github.com/usememos/memos/proto/gen/store"
)
type ActivityType string
const (
ActivityTypeMemoComment ActivityType = "MEMO_COMMENT"
)
func (t ActivityType) String() string {
return string(t)
}
type ActivityLevel string
const (
ActivityLevelInfo ActivityLevel = "INFO"
)
func (l ActivityLevel) String() string {
return string(l)
}
type Activity struct {
ID int32
// Standard fields
CreatorID int32
CreatedTs int64
// Domain specific fields
Type ActivityType
Level ActivityLevel
Payload *storepb.ActivityPayload
}
type FindActivity struct {
ID *int32
Type *ActivityType
// Pagination
Limit *int
Offset *int
}
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
return s.driver.CreateActivity(ctx, create)
}
func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) {
return s.driver.ListActivities(ctx, find)
}
func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) {
list, err := s.ListActivities(ctx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
return list[0], nil
}

View File

@ -1,101 +0,0 @@
package mysql
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
"google.golang.org/protobuf/encoding/protojson"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (d *DB) CreateActivity(ctx context.Context, create *store.Activity) (*store.Activity, error) {
payloadString := "{}"
if create.Payload != nil {
bytes, err := protojson.Marshal(create.Payload)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal activity payload")
}
payloadString = string(bytes)
}
fields := []string{"`creator_id`", "`type`", "`level`", "`payload`"}
placeholder := []string{"?", "?", "?", "?"}
args := []any{create.CreatorID, create.Type.String(), create.Level.String(), payloadString}
stmt := "INSERT INTO `activity` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")"
result, err := d.db.ExecContext(ctx, stmt, args...)
if err != nil {
return nil, errors.Wrap(err, "failed to execute statement")
}
id, err := result.LastInsertId()
if err != nil {
return nil, errors.Wrap(err, "failed to get last insert id")
}
id32 := int32(id)
list, err := d.ListActivities(ctx, &store.FindActivity{ID: &id32})
if err != nil || len(list) == 0 {
return nil, errors.Wrap(err, "failed to find activity")
}
return list[0], nil
}
func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*store.Activity, error) {
where, args := []string{"1 = 1"}, []any{}
if find.ID != nil {
where, args = append(where, "`id` = ?"), append(args, *find.ID)
}
if find.Type != nil {
where, args = append(where, "`type` = ?"), append(args, find.Type.String())
}
query := "SELECT `id`, `creator_id`, `type`, `level`, `payload`, UNIX_TIMESTAMP(`created_ts`) FROM `activity` WHERE " + strings.Join(where, " AND ") + " ORDER BY `created_ts` DESC"
if find.Limit != nil {
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
if find.Offset != nil {
query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset)
}
}
rows, err := d.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
list := []*store.Activity{}
for rows.Next() {
activity := &store.Activity{}
var payloadBytes []byte
if err := rows.Scan(
&activity.ID,
&activity.CreatorID,
&activity.Type,
&activity.Level,
&payloadBytes,
&activity.CreatedTs,
); err != nil {
return nil, err
}
payload := &storepb.ActivityPayload{}
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, payload); err != nil {
return nil, err
}
activity.Payload = payload
list = append(list, activity)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}

View File

@ -1,89 +0,0 @@
package postgres
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
"google.golang.org/protobuf/encoding/protojson"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (d *DB) CreateActivity(ctx context.Context, create *store.Activity) (*store.Activity, error) {
payloadString := "{}"
if create.Payload != nil {
bytes, err := protojson.Marshal(create.Payload)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal activity payload")
}
payloadString = string(bytes)
}
fields := []string{"creator_id", "type", "level", "payload"}
args := []any{create.CreatorID, create.Type.String(), create.Level.String(), payloadString}
stmt := "INSERT INTO activity (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts"
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
&create.ID,
&create.CreatedTs,
); err != nil {
return nil, err
}
return create, nil
}
func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*store.Activity, error) {
where, args := []string{"1 = 1"}, []any{}
if find.ID != nil {
where, args = append(where, "id = "+placeholder(len(args)+1)), append(args, *find.ID)
}
if find.Type != nil {
where, args = append(where, "type = "+placeholder(len(args)+1)), append(args, find.Type.String())
}
query := "SELECT id, creator_id, type, level, payload, created_ts FROM activity WHERE " + strings.Join(where, " AND ") + " ORDER BY created_ts DESC"
if find.Limit != nil {
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
if find.Offset != nil {
query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset)
}
}
rows, err := d.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
list := []*store.Activity{}
for rows.Next() {
activity := &store.Activity{}
var payloadBytes []byte
if err := rows.Scan(
&activity.ID,
&activity.CreatorID,
&activity.Type,
&activity.Level,
&payloadBytes,
&activity.CreatedTs,
); err != nil {
return nil, err
}
payload := &storepb.ActivityPayload{}
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, payload); err != nil {
return nil, err
}
activity.Payload = payload
list = append(list, activity)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}

View File

@ -1,91 +0,0 @@
package sqlite
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
"google.golang.org/protobuf/encoding/protojson"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (d *DB) CreateActivity(ctx context.Context, create *store.Activity) (*store.Activity, error) {
payloadString := "{}"
if create.Payload != nil {
bytes, err := protojson.Marshal(create.Payload)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal activity payload")
}
payloadString = string(bytes)
}
fields := []string{"`creator_id`", "`type`", "`level`", "`payload`"}
placeholder := []string{"?", "?", "?", "?"}
args := []any{create.CreatorID, create.Type.String(), create.Level.String(), payloadString}
stmt := "INSERT INTO activity (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`"
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
&create.ID,
&create.CreatedTs,
); err != nil {
return nil, err
}
return create, nil
}
func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*store.Activity, error) {
where, args := []string{"1 = 1"}, []any{}
if find.ID != nil {
where, args = append(where, "`id` = ?"), append(args, *find.ID)
}
if find.Type != nil {
where, args = append(where, "`type` = ?"), append(args, find.Type.String())
}
query := "SELECT `id`, `creator_id`, `type`, `level`, `payload`, `created_ts` FROM `activity` WHERE " + strings.Join(where, " AND ") + " ORDER BY `created_ts` DESC"
if find.Limit != nil {
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
if find.Offset != nil {
query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset)
}
}
rows, err := d.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
list := []*store.Activity{}
for rows.Next() {
activity := &store.Activity{}
var payloadBytes []byte
if err := rows.Scan(
&activity.ID,
&activity.CreatorID,
&activity.Type,
&activity.Level,
&payloadBytes,
&activity.CreatedTs,
); err != nil {
return nil, err
}
payload := &storepb.ActivityPayload{}
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, payload); err != nil {
return nil, err
}
activity.Payload = payload
list = append(list, activity)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}

View File

@ -13,10 +13,6 @@ type Driver interface {
IsInitialized(ctx context.Context) (bool, error)
// Activity model related methods.
CreateActivity(ctx context.Context, create *Activity) (*Activity, error)
ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error)
// Attachment model related methods.
CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error)
ListAttachments(ctx context.Context, find *FindAttachment) ([]*Attachment, error)

View File

@ -21,7 +21,6 @@ func (s InboxStatus) String() string {
}
// Inbox represents a notification in a user's inbox.
// It connects activities to users who should be notified.
type Inbox struct {
ID int32
CreatedTs int64

View File

@ -0,0 +1,14 @@
UPDATE `inbox` AS i
JOIN `activity` AS a
ON a.`id` = CAST(JSON_UNQUOTE(JSON_EXTRACT(i.`message`, '$.activityId')) AS UNSIGNED)
SET i.`message` = JSON_SET(
JSON_REMOVE(i.`message`, '$.activityId'),
'$.memoComment',
JSON_OBJECT(
'memoId',
JSON_EXTRACT(a.`payload`, '$.memoComment.memoId'),
'relatedMemoId',
JSON_EXTRACT(a.`payload`, '$.memoComment.relatedMemoId')
)
)
WHERE JSON_EXTRACT(i.`message`, '$.activityId') IS NOT NULL;

View File

@ -0,0 +1 @@
DROP TABLE `activity`;

View File

@ -67,16 +67,6 @@ CREATE TABLE `attachment` (
`payload` TEXT NOT NULL
);
-- activity
CREATE TABLE `activity` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`creator_id` INT NOT NULL,
`created_ts` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`type` VARCHAR(256) NOT NULL DEFAULT '',
`level` VARCHAR(256) NOT NULL DEFAULT 'INFO',
`payload` TEXT NOT NULL
);
-- idp
CREATE TABLE `idp` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,

View File

@ -0,0 +1,13 @@
UPDATE inbox AS i
SET message = jsonb_set(
i.message::jsonb - 'activityId',
'{memoComment}',
jsonb_build_object(
'memoId',
a.payload->'memoComment'->'memoId',
'relatedMemoId',
a.payload->'memoComment'->'relatedMemoId'
)
)::text
FROM activity AS a
WHERE (i.message::jsonb->>'activityId')::integer = a.id;

View File

@ -0,0 +1 @@
DROP TABLE activity;

View File

@ -67,16 +67,6 @@ CREATE TABLE attachment (
payload TEXT NOT NULL DEFAULT '{}'
);
-- activity
CREATE TABLE activity (
id SERIAL PRIMARY KEY,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
type TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL DEFAULT 'INFO',
payload JSONB NOT NULL DEFAULT '{}'
);
-- idp
CREATE TABLE idp (
id SERIAL PRIMARY KEY,

View File

@ -0,0 +1,25 @@
UPDATE inbox
SET message = json_set(
json_remove(message, '$.activityId'),
'$.memoComment',
json_object(
'memoId',
(
SELECT json_extract(activity.payload, '$.memoComment.memoId')
FROM activity
WHERE activity.id = json_extract(inbox.message, '$.activityId')
),
'relatedMemoId',
(
SELECT json_extract(activity.payload, '$.memoComment.relatedMemoId')
FROM activity
WHERE activity.id = json_extract(inbox.message, '$.activityId')
)
)
)
WHERE json_extract(message, '$.activityId') IS NOT NULL
AND EXISTS (
SELECT 1
FROM activity
WHERE activity.id = json_extract(inbox.message, '$.activityId')
);

View File

@ -0,0 +1 @@
DROP TABLE activity;

View File

@ -68,16 +68,6 @@ CREATE TABLE attachment (
payload TEXT NOT NULL DEFAULT '{}'
);
-- activity
CREATE TABLE activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
type TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
payload TEXT NOT NULL DEFAULT '{}'
);
-- idp
CREATE TABLE idp (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -1,380 +0,0 @@
package test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func TestActivityStore(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
create := &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
}
activity, err := ts.CreateActivity(ctx, create)
require.NoError(t, err)
require.NotNil(t, activity)
activities, err := ts.ListActivities(ctx, &store.FindActivity{
ID: &activity.ID,
})
require.NoError(t, err)
require.Equal(t, 1, len(activities))
require.Equal(t, activity, activities[0])
ts.Close()
}
func TestActivityGetByID(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
// Get activity by ID
found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID})
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, activity.ID, found.ID)
// Get non-existent activity
nonExistentID := int32(99999)
notFound, err := ts.GetActivity(ctx, &store.FindActivity{ID: &nonExistentID})
require.NoError(t, err)
require.Nil(t, notFound)
ts.Close()
}
func TestActivityListMultiple(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create multiple activities
_, err = ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
_, err = ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
// List all activities
allActivities, err := ts.ListActivities(ctx, &store.FindActivity{})
require.NoError(t, err)
require.Equal(t, 2, len(allActivities))
// List by type
commentType := store.ActivityTypeMemoComment
commentActivities, err := ts.ListActivities(ctx, &store.FindActivity{Type: &commentType})
require.NoError(t, err)
require.Equal(t, 2, len(commentActivities))
require.Equal(t, store.ActivityTypeMemoComment, commentActivities[0].Type)
ts.Close()
}
func TestActivityListByType(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create activities with MEMO_COMMENT type
_, err = ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
_, err = ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
// List by type
activityType := store.ActivityTypeMemoComment
activities, err := ts.ListActivities(ctx, &store.FindActivity{Type: &activityType})
require.NoError(t, err)
require.Len(t, activities, 2)
for _, activity := range activities {
require.Equal(t, store.ActivityTypeMemoComment, activity.Type)
}
ts.Close()
}
func TestActivityPayloadMemoComment(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create activity with MemoComment payload
memoID := int32(123)
relatedMemoID := int32(456)
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{
MemoComment: &storepb.ActivityMemoCommentPayload{
MemoId: memoID,
RelatedMemoId: relatedMemoID,
},
},
})
require.NoError(t, err)
require.NotNil(t, activity.Payload)
require.NotNil(t, activity.Payload.MemoComment)
require.Equal(t, memoID, activity.Payload.MemoComment.MemoId)
require.Equal(t, relatedMemoID, activity.Payload.MemoComment.RelatedMemoId)
// Verify payload is preserved when listing
found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID})
require.NoError(t, err)
require.NotNil(t, found.Payload.MemoComment)
require.Equal(t, memoID, found.Payload.MemoComment.MemoId)
require.Equal(t, relatedMemoID, found.Payload.MemoComment.RelatedMemoId)
ts.Close()
}
func TestActivityEmptyPayload(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create activity with empty payload
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
require.NotNil(t, activity.Payload)
// Verify empty payload is handled correctly
found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID})
require.NoError(t, err)
require.NotNil(t, found.Payload)
require.Nil(t, found.Payload.MemoComment)
ts.Close()
}
func TestActivityLevel(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create activity with INFO level
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
require.Equal(t, store.ActivityLevelInfo, activity.Level)
// Verify level is preserved when listing
found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID})
require.NoError(t, err)
require.Equal(t, store.ActivityLevelInfo, found.Level)
ts.Close()
}
func TestActivityCreatorID(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user1, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser)
require.NoError(t, err)
// Create activity for user1
activity1, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user1.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
require.Equal(t, user1.ID, activity1.CreatorID)
// Create activity for user2
activity2, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user2.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
require.Equal(t, user2.ID, activity2.CreatorID)
// List all and verify creator IDs
activities, err := ts.ListActivities(ctx, &store.FindActivity{})
require.NoError(t, err)
require.Len(t, activities, 2)
ts.Close()
}
func TestActivityCreatedTs(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
require.NotZero(t, activity.CreatedTs)
// Verify timestamp is preserved when listing
found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID})
require.NoError(t, err)
require.Equal(t, activity.CreatedTs, found.CreatedTs)
ts.Close()
}
func TestActivityListEmpty(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// List activities when none exist
activities, err := ts.ListActivities(ctx, &store.FindActivity{})
require.NoError(t, err)
require.Len(t, activities, 0)
ts.Close()
}
func TestActivityListWithIDAndType(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
// List with both ID and Type filters
activityType := store.ActivityTypeMemoComment
activities, err := ts.ListActivities(ctx, &store.FindActivity{
ID: &activity.ID,
Type: &activityType,
})
require.NoError(t, err)
require.Len(t, activities, 1)
require.Equal(t, activity.ID, activities[0].ID)
ts.Close()
}
func TestActivityPayloadComplexMemoComment(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create a memo first to use its ID
memo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "test-memo-for-activity",
CreatorID: user.ID,
Content: "Test memo content",
Visibility: store.Public,
})
require.NoError(t, err)
// Create comment memo
commentMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "comment-memo",
CreatorID: user.ID,
Content: "This is a comment",
Visibility: store.Public,
})
require.NoError(t, err)
// Create activity with real memo IDs
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{
MemoComment: &storepb.ActivityMemoCommentPayload{
MemoId: memo.ID,
RelatedMemoId: commentMemo.ID,
},
},
})
require.NoError(t, err)
require.Equal(t, memo.ID, activity.Payload.MemoComment.MemoId)
require.Equal(t, commentMemo.ID, activity.Payload.MemoComment.RelatedMemoId)
// Verify payload is preserved
found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID})
require.NoError(t, err)
require.Equal(t, memo.ID, found.Payload.MemoComment.MemoId)
require.Equal(t, commentMemo.ID, found.Payload.MemoComment.RelatedMemoId)
ts.Close()
}

View File

@ -329,27 +329,37 @@ func TestInboxMessagePayload(t *testing.T) {
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create inbox with message payload containing activity ID
activityID := int32(123)
// Create inbox with message payload containing memo references.
memoID := int32(123)
relatedMemoID := int32(456)
inbox, err := ts.CreateInbox(ctx, &store.Inbox{
SenderID: 0,
ReceiverID: user.ID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_MEMO_COMMENT,
ActivityId: &activityID,
Type: storepb.InboxMessage_MEMO_COMMENT,
Payload: &storepb.InboxMessage_MemoComment{
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
MemoId: memoID,
RelatedMemoId: relatedMemoID,
},
},
},
})
require.NoError(t, err)
require.NotNil(t, inbox.Message)
require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type)
require.Equal(t, activityID, *inbox.Message.ActivityId)
require.NotNil(t, inbox.Message.GetMemoComment())
require.Equal(t, memoID, inbox.Message.GetMemoComment().MemoId)
require.Equal(t, relatedMemoID, inbox.Message.GetMemoComment().RelatedMemoId)
// List and verify payload is preserved
inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user.ID})
require.NoError(t, err)
require.Len(t, inboxes, 1)
require.Equal(t, activityID, *inboxes[0].Message.ActivityId)
require.NotNil(t, inboxes[0].Message.GetMemoComment())
require.Equal(t, memoID, inboxes[0].Message.GetMemoComment().MemoId)
require.Equal(t, relatedMemoID, inboxes[0].Message.GetMemoComment().RelatedMemoId)
ts.Close()
}
@ -452,33 +462,43 @@ func TestInboxMessageTypeFilterWithPayload(t *testing.T) {
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create inbox with full payload
activityID := int32(456)
// Create inbox with full payload.
memoID := int32(456)
_, err = ts.CreateInbox(ctx, &store.Inbox{
SenderID: 0,
ReceiverID: user.ID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_MEMO_COMMENT,
ActivityId: &activityID,
Type: storepb.InboxMessage_MEMO_COMMENT,
Payload: &storepb.InboxMessage_MemoComment{
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
MemoId: memoID,
RelatedMemoId: 654,
},
},
},
})
require.NoError(t, err)
// Create inbox with different type but also has payload
otherActivityID := int32(789)
// Create inbox with different type but also has payload.
otherMemoID := int32(789)
_, err = ts.CreateInbox(ctx, &store.Inbox{
SenderID: 0,
ReceiverID: user.ID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_TYPE_UNSPECIFIED,
ActivityId: &otherActivityID,
Type: storepb.InboxMessage_TYPE_UNSPECIFIED,
Payload: &storepb.InboxMessage_MemoComment{
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
MemoId: otherMemoID,
RelatedMemoId: 987,
},
},
},
})
require.NoError(t, err)
// Filter by type should work correctly even with complex JSON payload
// Filter by type should work correctly even with complex JSON payload.
memoCommentType := storepb.InboxMessage_MEMO_COMMENT
inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{
ReceiverID: &user.ID,
@ -486,7 +506,8 @@ func TestInboxMessageTypeFilterWithPayload(t *testing.T) {
})
require.NoError(t, err)
require.Len(t, inboxes, 1)
require.Equal(t, activityID, *inboxes[0].Message.ActivityId)
require.NotNil(t, inboxes[0].Message.GetMemoComment())
require.Equal(t, memoID, inboxes[0].Message.GetMemoComment().MemoId)
ts.Close()
}

View File

@ -7,8 +7,10 @@ import (
"testing"
"time"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
@ -87,6 +89,58 @@ func TestMigrationWithData(t *testing.T) {
require.Equal(t, "Data before migration re-run", memo.Content, "memo content should be preserved")
}
func TestMigrationBackfillsInboxMemoCommentPayload(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
commentMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "migration-comment-memo",
CreatorID: user.ID,
Content: "Comment memo",
Visibility: store.Public,
})
require.NoError(t, err)
relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "migration-related-memo",
CreatorID: user.ID,
Content: "Related memo",
Visibility: store.Public,
})
require.NoError(t, err)
driver := getDriverFromEnv()
require.NoError(t, createLegacyActivityTable(ctx, ts, driver))
require.NoError(t, insertLegacyInboxActivity(ctx, ts, driver, user.ID, commentMemo.ID, relatedMemo.ID))
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_BASIC,
Value: &storepb.InstanceSetting_BasicSetting{
BasicSetting: &storepb.InstanceBasicSetting{
SchemaVersion: "0.27.2",
},
},
})
require.NoError(t, err)
err = ts.Migrate(ctx)
require.NoError(t, err)
messageType := storepb.InboxMessage_MEMO_COMMENT
inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{
ReceiverID: &user.ID,
MessageType: &messageType,
})
require.NoError(t, err)
require.Len(t, inboxes, 1)
require.NotNil(t, inboxes[0].Message)
require.NotNil(t, inboxes[0].Message.GetMemoComment())
require.Equal(t, commentMemo.ID, inboxes[0].Message.GetMemoComment().MemoId)
require.Equal(t, relatedMemo.ID, inboxes[0].Message.GetMemoComment().RelatedMemoId)
}
// TestMigrationMultipleReRuns verifies that migration is idempotent
// even when run multiple times in succession.
func TestMigrationMultipleReRuns(t *testing.T) {
@ -111,6 +165,69 @@ func TestMigrationMultipleReRuns(t *testing.T) {
require.Equal(t, initialVersion, finalVersion, "version should remain unchanged after multiple re-runs")
}
func createLegacyActivityTable(ctx context.Context, ts *store.Store, driver string) error {
var stmt string
switch driver {
case "sqlite":
stmt = `CREATE TABLE activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
type TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL DEFAULT 'INFO',
payload TEXT NOT NULL DEFAULT '{}'
);`
case "mysql":
stmt = `CREATE TABLE activity (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
creator_id INT NOT NULL,
created_ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
type VARCHAR(256) NOT NULL DEFAULT '',
level VARCHAR(256) NOT NULL DEFAULT 'INFO',
payload TEXT NOT NULL
);`
case "postgres":
stmt = `CREATE TABLE activity (
id SERIAL PRIMARY KEY,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
type TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL DEFAULT 'INFO',
payload JSONB NOT NULL DEFAULT '{}'
);`
default:
return errors.Errorf("unsupported driver: %s", driver)
}
_, err := ts.GetDriver().GetDB().ExecContext(ctx, stmt)
return err
}
func insertLegacyInboxActivity(ctx context.Context, ts *store.Store, driver string, receiverID, memoID, relatedMemoID int32) error {
var insertActivityStmt string
var insertInboxStmt string
payload := fmt.Sprintf(`{"memoComment":{"memoId":%d,"relatedMemoId":%d}}`, memoID, relatedMemoID)
message := `{"type":"MEMO_COMMENT","activityId":1}`
switch driver {
case "sqlite", "mysql":
insertActivityStmt = `INSERT INTO activity (id, creator_id, type, level, payload) VALUES (?, ?, ?, ?, ?)`
insertInboxStmt = `INSERT INTO inbox (sender_id, receiver_id, status, message) VALUES (?, ?, ?, ?)`
case "postgres":
insertActivityStmt = `INSERT INTO activity (id, creator_id, type, level, payload) VALUES ($1, $2, $3, $4, $5::jsonb)`
insertInboxStmt = `INSERT INTO inbox (sender_id, receiver_id, status, message) VALUES ($1, $2, $3, $4)`
default:
return errors.Errorf("unsupported driver: %s", driver)
}
if _, err := ts.GetDriver().GetDB().ExecContext(ctx, insertActivityStmt, 1, receiverID, "MEMO_COMMENT", "INFO", payload); err != nil {
return err
}
if _, err := ts.GetDriver().GetDB().ExecContext(ctx, insertInboxStmt, receiverID, receiverID, store.UNREAD, message); err != nil {
return err
}
return nil
}
// TestMigrationFromStableVersion verifies that upgrading from a stable Memos version
// to the current version works correctly. This is the critical upgrade path test.
//