memos/server/router/api/v1/memo_service_comments.go

241 lines
7.9 KiB
Go

package v1
import (
"context"
stderrors "errors"
"log/slog"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// CreateMemoComment creates a comment on an existing memo.
func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.CreateMemoCommentRequest) (*v1pb.Memo, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
if relatedMemo == nil {
return nil, status.Errorf(codes.NotFound, "memo not found")
}
// Check memo visibility before allowing comment.
user, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if err := s.checkMemoReadAccess(ctx, relatedMemo); err != nil {
return nil, err
}
if relatedMemo.RowStatus != store.Normal {
return nil, status.Errorf(codes.FailedPrecondition, "cannot comment on an archived memo")
}
if request.Comment == nil {
return nil, status.Errorf(codes.InvalidArgument, "comment is required")
}
memoUID, err = ValidateAndGenerateUID(request.CommentId)
if err != nil {
return nil, err
}
if memoUID == relatedMemo.UID {
return nil, status.Error(codes.InvalidArgument, "a memo cannot comment on itself")
}
prepared, err := s.prepareMemoCreate(ctx, user, request.Comment, memoUID)
if err != nil {
return nil, err
}
if err := s.createMemoWithMutation(ctx, user, prepared.memo, &relatedMemo.ID, prepared.attachments, prepared.requiredAttachmentIDs, prepared.referenceRelations); err != nil {
return nil, mapMemoCreateError(err, memoUID, "failed to create memo comment")
}
memo := prepared.memo
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoID: &memo.ID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to load memo comment attachments")
}
relations, err := s.loadMemoRelations(ctx, memo)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to load memo relations")
}
memoComment, err := s.convertMemoFromStore(ctx, memo, nil, attachments, relations)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert memo comment")
}
creatorID := user.ID
relatedCreator, err := s.Store.GetUser(ctx, &store.FindUser{ID: &relatedMemo.CreatorID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get related memo creator")
}
if creatorID != relatedMemo.CreatorID &&
s.canUserAccessMentionMemos(ctx, relatedCreator, memo, relatedMemo) {
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
SenderID: creatorID,
ReceiverID: relatedMemo.CreatorID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
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")
}
}
if err := s.DispatchMemoCommentCreatedWebhook(ctx, memo, relatedMemo, relatedMemo.CreatorID); err != nil {
slog.Warn("Failed to dispatch memo comment created webhook", slog.Any("err", err))
}
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, relatedMemo, "")
s.SSEHub.publishMemoChanged()
return memoComment, nil
}
func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListMemoCommentsRequest) (*v1pb.ListMemoCommentsResponse, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
if memo == nil {
return nil, status.Errorf(codes.NotFound, "memo not found")
}
if err := s.checkMemoReadAccess(ctx, memo); err != nil {
return nil, err
}
accessScope, _, err := s.resolveMemoAccessScope(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "%v", err)
}
var limit, offset int
if request.PageToken != "" {
var pageToken v1pb.PageToken
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
}
limit = normalizePageSize(pageToken.Limit)
offset = max(int(pageToken.Offset), 0)
} else {
limit = normalizePageSize(request.PageSize)
}
limitPlusOne := limit + 1
normal := store.Normal
memos, err := s.Store.ListMemos(ctx, &store.FindMemo{
CommentContextMemoID: &memo.ID,
RowStatus: &normal,
Access: accessScope,
Limit: &limitPlusOne,
Offset: &offset,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo comments")
}
nextPageToken := ""
if len(memos) == limitPlusOne {
memos = memos[:limit]
nextPageToken, err = getPageToken(limit, offset+limit)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err)
}
}
if len(memos) == 0 {
response := &v1pb.ListMemoCommentsResponse{
Memos: []*v1pb.Memo{},
NextPageToken: nextPageToken,
}
return response, nil
}
memoIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
memoIDs = append(memoIDs, memo.ID)
}
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{MemoIDList: memoIDs})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list reactions")
}
memoReactionsMap := make(map[int32][]*store.Reaction)
for _, reaction := range reactions {
memoReactionsMap[reaction.MemoID] = append(memoReactionsMap[reaction.MemoID], reaction)
}
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDs})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list attachments")
}
attachmentMap := make(map[int32][]*store.Attachment)
for _, attachment := range attachments {
attachmentMap[*attachment.MemoID] = append(attachmentMap[*attachment.MemoID], attachment)
}
// RELATIONS (batch load to avoid N+1)
relationMap, err := s.batchConvertMemoRelations(ctx, memos, false)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
}
creatorIDs := make([]int32, 0, len(memos)+len(reactions))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
for _, reaction := range reactions {
creatorIDs = append(creatorIDs, reaction.CreatorID)
}
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
var memosResponse []*v1pb.Memo
for _, m := range memos {
reactions := memoReactionsMap[m.ID]
attachments := attachmentMap[m.ID]
relations := relationMap[m.ID]
memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, m, reactions, attachments, relations, creatorMap)
if err != nil {
if stderrors.Is(err, errMemoCreatorNotFound) {
slog.Warn("Skipping memo comment with missing creator",
slog.Int64("memo_id", int64(m.ID)),
slog.String("memo_uid", m.UID),
slog.Int64("creator_id", int64(m.CreatorID)),
slog.String("parent_name", request.Name),
)
continue
}
return nil, errors.Wrap(err, "failed to convert memo")
}
memosResponse = append(memosResponse, memoMessage)
}
response := &v1pb.ListMemoCommentsResponse{
Memos: memosResponse,
NextPageToken: nextPageToken,
}
return response, nil
}