Skip to content

feat: support merging tag #1315

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions i18n/en_US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ backend:
other: Edit
undelete:
other: Undelete
merge:
other: Merge
role:
name:
user:
Expand Down Expand Up @@ -1041,6 +1043,15 @@ ui:
<p>Please remove the synonyms from this tag first.</p>
tip: Are you sure you wish to delete?
close: Close
merge:
title: Merge tag
source_tag_title: Source tag
source_tag_description: The source tag and its associated data will be remapped to the target tag.
target_tag_title: Target tag
target_tag_description: A synonym between these two tags will be created after merging.
no_results: No tags matched
btn_submit: Submit
btn_close: Close
edit_tag:
title: Edit Tag
default_reason: Edit tag
Expand Down
29 changes: 29 additions & 0 deletions internal/controller/tag_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ func (tc *TagController) GetTagInfo(ctx *gin.Context) {
req.CanEdit = canList[0]
req.CanDelete = canList[1]
req.CanRecover = canList[2]
req.CanMerge = middleware.GetUserIsAdminModerator(ctx)

resp, err := tc.tagService.GetTagInfo(ctx, req)
handler.HandleResponse(ctx, err, resp)
Expand Down Expand Up @@ -355,3 +356,31 @@ func (tc *TagController) UpdateTagSynonym(ctx *gin.Context) {
err = tc.tagService.UpdateTagSynonym(ctx, req)
handler.HandleResponse(ctx, err, nil)
}

// MergeTag merge tag
// @Summary merge tag
// @Description merge tag
// @Security ApiKeyAuth
// @Tags Tag
// @Accept json
// @Produce json
// @Param data body schema.AddTagReq true "tag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/tag/merge [post]
func (tc *TagController) MergeTag(ctx *gin.Context) {
req := &schema.MergeTagReq{}
if handler.BindAndCheck(ctx, req) {
return
}

isAdminModerator := middleware.GetUserIsAdminModerator(ctx)
if !isAdminModerator {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}

req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := tc.tagService.MergeTag(ctx, req)

handler.HandleResponse(ctx, err, nil)
}
107 changes: 107 additions & 0 deletions internal/repo/activity_common/follow.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package activity_common

import (
"context"
"time"

"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/base/reason"
Expand All @@ -30,6 +31,8 @@ import (
"github.com/apache/answer/pkg/obj"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
"xorm.io/builder"
"xorm.io/xorm"
)

// FollowRepo follow repository
Expand Down Expand Up @@ -156,3 +159,107 @@ func (ar *FollowRepo) IsFollowed(ctx context.Context, userID, objectID string) (
return true, nil
}
}

// MigrateFollowers migrate followers from source object to target object
func (ar *FollowRepo) MigrateFollowers(ctx context.Context, sourceObjectID, targetObjectID, action string) error {
// if source object id and target object id are same type
sourceObjectTypeStr, err := obj.GetObjectTypeStrByObjectID(sourceObjectID)
if err != nil {
return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
targetObjectTypeStr, err := obj.GetObjectTypeStrByObjectID(targetObjectID)
if err != nil {
return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
if sourceObjectTypeStr != targetObjectTypeStr {
return errors.InternalServer(reason.DisallowFollow).WithMsg("not same object type")
}
activityType, err := ar.activityRepo.GetActivityTypeByObjectType(ctx, sourceObjectTypeStr, action)
if err != nil {
return err
}

// 1. get all user ids who follow the source object
userIDs, err := ar.GetFollowUserIDs(ctx, sourceObjectID)
if err != nil {
log.Errorf("MigrateFollowers: failed to get user ids who follow %s: %v", sourceObjectID, err)
return err
}

_, err = ar.data.DB.Transaction(func(session *xorm.Session) (result any, err error) {
session = session.Context(ctx)
// 1. delete all follows of the source object
_, err = session.Table(entity.Activity{}.TableName()).
Where(builder.Eq{
"object_id": sourceObjectID,
"activity_type": activityType,
}).
Delete(&entity.Activity{})
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

// 2. update cancel status to active for target tag if source tag followers is active
_, err = session.Table(entity.Activity{}.TableName()).
Where(builder.Eq{
"object_id": targetObjectID,
"activity_type": activityType,
}).
And(builder.In("user_id", userIDs)).
Cols("cancelled").
Update(&entity.Activity{
Cancelled: entity.ActivityAvailable,
})
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

// 3. get existing follows of the target object
targetFollowers := make([]string, 0)
err = session.Table(entity.Activity{}.TableName()).
Where(builder.Eq{
"object_id": targetObjectID,
"activity_type": activityType,
"cancelled": entity.ActivityAvailable,
}).
Cols("user_id").
Find(&targetFollowers)
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

// 4. filter out user ids that already follow the target object and create new activity
// Create a map for faster lookup of existing followers
existingFollowers := make(map[string]bool)
for _, uid := range targetFollowers {
existingFollowers[uid] = true
}

// Filter out users who already follow the target
newFollowers := make([]string, 0)
for _, uid := range userIDs {
if !existingFollowers[uid] {
newFollowers = append(newFollowers, uid)
}
}

// Create new activities for the filtered users
for _, uid := range newFollowers {
activity := &entity.Activity{
UserID: uid,
ObjectID: targetObjectID,
OriginalObjectID: targetObjectID,
ActivityType: activityType,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Cancelled: entity.ActivityAvailable,
}
if _, err = session.Insert(activity); err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
}
return nil, nil
})

return err
}
55 changes: 55 additions & 0 deletions internal/repo/tag/tag_rel_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/apache/answer/internal/service/unique"
"github.com/apache/answer/pkg/uid"
"github.com/segmentfault/pacman/errors"
"xorm.io/xorm"
)

// tagRelRepo tag rel repository
Expand Down Expand Up @@ -203,3 +204,57 @@ func (tr *tagRelRepo) GetTagRelDefaultStatusByObjectID(ctx context.Context, obje
}
return entity.TagRelStatusAvailable, nil
}

// MigrateTagObjects migrate tag objects
func (tr *tagRelRepo) MigrateTagObjects(ctx context.Context, sourceTagId, targetTagId string) error {
_, err := tr.data.DB.Transaction(func(session *xorm.Session) (result any, err error) {
// 1. Get all objects related to source tag
var sourceObjects []entity.TagRel
err = session.Where("tag_id = ?", sourceTagId).Find(&sourceObjects)
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

// 2. Get existing target tag relations
var existingTargets []entity.TagRel
err = session.Where("tag_id = ?", targetTagId).Find(&existingTargets)
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

// Create map of existing target objects for quick lookup
existingMap := make(map[string]bool)
for _, target := range existingTargets {
existingMap[target.ObjectID] = true
}

// 3. Create new relations for objects not already tagged with target
newRelations := make([]*entity.TagRel, 0)
for _, source := range sourceObjects {
if !existingMap[source.ObjectID] {
newRelations = append(newRelations, &entity.TagRel{
TagID: targetTagId,
ObjectID: source.ObjectID,
Status: source.Status,
})
}
}

if len(newRelations) > 0 {
_, err = session.Insert(newRelations)
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
}

// 4. Remove old relations
_, err = session.Where("tag_id = ?", sourceTagId).Delete(&entity.TagRel{})
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

return nil, nil
})

return err
}
1 change: 1 addition & 0 deletions internal/router/answer_api_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
r.POST("/tag/recover", a.tagController.RecoverTag)
r.DELETE("/tag", a.tagController.RemoveTag)
r.PUT("/tag/synonym", a.tagController.UpdateTagSynonym)
r.POST("/tag/merge", a.tagController.MergeTag)

// collection
r.POST("/collection/switch", a.collectionController.CollectionSwitch)
Expand Down
16 changes: 16 additions & 0 deletions internal/schema/tag_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type GetTagInfoReq struct {
UserID string `json:"-"`
CanEdit bool `json:"-"`
CanDelete bool `json:"-"`
CanMerge bool `json:"-"`
CanRecover bool `json:"-"`
}

Expand Down Expand Up @@ -300,8 +301,23 @@ type GetFollowingTagsResp struct {

// GetTagBasicResp get tag basic response
type GetTagBasicResp struct {
TagID string `json:"tag_id"`
SlugName string `json:"slug_name"`
DisplayName string `json:"display_name"`
Recommend bool `json:"recommend"`
Reserved bool `json:"reserved"`
}

// MergeTagReq merge tag request
type MergeTagReq struct {
// source tag id
SourceTagID string `validate:"required" json:"source_tag_id"`
// target tag id
TargetTagID string `validate:"required" json:"target_tag_id"`
// user id
UserID string `json:"-"`
}

// MergeTagResp merge tag response
type MergeTagResp struct {
}
1 change: 1 addition & 0 deletions internal/service/activity_common/follow.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ type FollowRepo interface {
GetFollowAmount(ctx context.Context, objectID string) (followAmount int, err error)
GetFollowUserIDs(ctx context.Context, objectID string) (userIDs []string, err error)
IsFollowed(ctx context.Context, userId, objectId string) (bool, error)
MigrateFollowers(ctx context.Context, sourceObjectID, targetObjectID, action string) error
}
2 changes: 2 additions & 0 deletions internal/service/permission/permission_name.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const (
TagEditSlugName = "tag.edit_slug_name"
TagEditWithoutReview = "tag.edit_without_review"
TagDelete = "tag.delete"
TagMerge = "tag.merge"
TagSynonym = "tag.synonym"
LinkUrlLimit = "link.url_limit"
VoteDetail = "vote.detail"
Expand All @@ -68,6 +69,7 @@ const (
reportActionName = "action.report"
editActionName = "action.edit"
deleteActionName = "action.delete"
mergeActionName = "action.merge"
undeleteActionName = "action.undelete"
closeActionName = "action.close"
reopenActionName = "action.reopen"
Expand Down
11 changes: 10 additions & 1 deletion internal/service/permission/tag_permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package permission

import (
"context"

"github.com/apache/answer/internal/entity"

"github.com/apache/answer/internal/base/handler"
Expand All @@ -29,7 +30,7 @@ import (
)

// GetTagPermission get tag permission
func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canRecover bool) (
func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canMerge, canRecover bool) (
actions []*schema.PermissionMemberAction) {
lang := handler.GetLangByCtx(ctx)
actions = make([]*schema.PermissionMemberAction, 0)
Expand All @@ -49,6 +50,14 @@ func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canRe
})
}

if canMerge && status != entity.TagStatusDeleted {
actions = append(actions, &schema.PermissionMemberAction{
Action: "merge",
Name: translator.Tr(lang, mergeActionName),
Type: "edit",
})
}

if canRecover && status == entity.QuestionStatusDeleted {
actions = append(actions, &schema.PermissionMemberAction{
Action: "undelete",
Expand Down
Loading