@@ -753,6 +753,165 @@ func (s *Server) GetGroupStats(c *gin.Context) {
753753 response .Success (c , resp )
754754}
755755
756+ // GroupCopyRequest defines the payload for copying a group.
757+ type GroupCopyRequest struct {
758+ CopyKeys string `json:"copy_keys"` // "none"|"valid_only"|"all"
759+ }
760+
761+ // GroupCopyResponse defines the response for group copy operation.
762+ type GroupCopyResponse struct {
763+ Group * GroupResponse `json:"group"`
764+ }
765+
766+ // generateUniqueGroupName generates a unique group name by appending _copy and numbers if needed.
767+ func (s * Server ) generateUniqueGroupName (baseName string ) string {
768+ var groups []models.Group
769+ if err := s .DB .Select ("name" ).Find (& groups ).Error ; err != nil {
770+ return baseName + "_copy"
771+ }
772+
773+ // Create a map of existing names for quick lookup
774+ existingNames := make (map [string ]bool )
775+ for _ , group := range groups {
776+ existingNames [group .Name ] = true
777+ }
778+
779+ // Try base name with _copy suffix first
780+ copyName := baseName + "_copy"
781+ if ! existingNames [copyName ] {
782+ return copyName
783+ }
784+
785+ // Try appending numbers to _copy suffix
786+ for i := 2 ; i <= 1000 ; i ++ {
787+ candidate := fmt .Sprintf ("%s_copy_%d" , baseName , i )
788+ if ! existingNames [candidate ] {
789+ return candidate
790+ }
791+ }
792+
793+ return copyName
794+ }
795+
796+ // CopyGroup handles copying a group with optional content.
797+ func (s * Server ) CopyGroup (c * gin.Context ) {
798+ id , err := strconv .Atoi (c .Param ("id" ))
799+ if err != nil {
800+ response .Error (c , app_errors .NewAPIError (app_errors .ErrBadRequest , "Invalid group ID format" ))
801+ return
802+ }
803+ sourceGroupID := uint (id )
804+
805+ var req GroupCopyRequest
806+ if err := c .ShouldBindJSON (& req ); err != nil {
807+ response .Error (c , app_errors .NewAPIError (app_errors .ErrInvalidJSON , err .Error ()))
808+ return
809+ }
810+
811+ // Validate copy keys option
812+ if req .CopyKeys != "" && req .CopyKeys != "none" && req .CopyKeys != "valid_only" && req .CopyKeys != "all" {
813+ response .Error (c , app_errors .NewAPIError (app_errors .ErrValidation , "Invalid copy_keys value. Must be 'none', 'valid_only', or 'all'" ))
814+ return
815+ }
816+ if req .CopyKeys == "" {
817+ req .CopyKeys = "all"
818+ }
819+
820+ // Check if source group exists
821+ var sourceGroup models.Group
822+ if err := s .DB .First (& sourceGroup , sourceGroupID ).Error ; err != nil {
823+ response .Error (c , app_errors .ParseDBError (err ))
824+ return
825+ }
826+
827+ // Start transaction
828+ tx := s .DB .Begin ()
829+ if tx .Error != nil {
830+ response .Error (c , app_errors .ErrDatabase )
831+ return
832+ }
833+ defer tx .Rollback ()
834+
835+ // Create new group by copying source group and overriding specific fields
836+ newGroup := sourceGroup
837+ newGroup .ID = 0
838+ newGroup .Name = s .generateUniqueGroupName (sourceGroup .Name )
839+ if sourceGroup .DisplayName != "" {
840+ newGroup .DisplayName = sourceGroup .DisplayName + " Copy"
841+ }
842+ newGroup .CreatedAt = time.Time {}
843+ newGroup .UpdatedAt = time.Time {}
844+ newGroup .LastValidatedAt = nil
845+
846+ // Create the new group
847+ if err := tx .Create (& newGroup ).Error ; err != nil {
848+ response .Error (c , app_errors .ParseDBError (err ))
849+ return
850+ }
851+
852+ // Prepare key data for async import task
853+ var sourceKeyValues []string
854+
855+ if req .CopyKeys != "none" {
856+ var sourceKeys []models.APIKey
857+ query := tx .Where ("group_id = ?" , sourceGroupID )
858+
859+ // Filter by status if only copying valid keys
860+ if req .CopyKeys == "valid_only" {
861+ query = query .Where ("status = ?" , models .KeyStatusActive )
862+ }
863+
864+ if err := query .Find (& sourceKeys ).Error ; err != nil {
865+ response .Error (c , app_errors .ParseDBError (err ))
866+ return
867+ }
868+
869+ // Extract key values for async import task
870+ for _ , sourceKey := range sourceKeys {
871+ sourceKeyValues = append (sourceKeyValues , sourceKey .KeyValue )
872+ }
873+ }
874+
875+ // Commit transaction
876+ if err := tx .Commit ().Error ; err != nil {
877+ response .Error (c , app_errors .ErrDatabase )
878+ return
879+ }
880+
881+ // Update caches after successful transaction
882+ if err := s .GroupManager .Invalidate (); err != nil {
883+ logrus .WithContext (c .Request .Context ()).WithError (err ).Error ("failed to invalidate group cache" )
884+ }
885+
886+ // Start async key import task if there are keys to copy (reuse existing logic)
887+ if len (sourceKeyValues ) > 0 {
888+ // Convert key values array to text format expected by KeyImportService
889+ keysText := strings .Join (sourceKeyValues , "\n " )
890+
891+ // Directly reuse the AddMultipleKeysAsync logic from key_handler.go
892+ if _ , err := s .KeyImportService .StartImportTask (& newGroup , keysText ); err != nil {
893+ logrus .WithFields (logrus.Fields {
894+ "groupId" : newGroup .ID ,
895+ "keyCount" : len (sourceKeyValues ),
896+ "error" : err ,
897+ }).Error ("Failed to start async key import task for group copy" )
898+ } else {
899+ logrus .WithFields (logrus.Fields {
900+ "groupId" : newGroup .ID ,
901+ "keyCount" : len (sourceKeyValues ),
902+ }).Info ("Started async key import task for group copy" )
903+ }
904+ }
905+
906+ // Prepare response
907+ groupResponse := s .newGroupResponse (& newGroup )
908+ copyResponse := & GroupCopyResponse {
909+ Group : groupResponse ,
910+ }
911+
912+ response .Success (c , copyResponse )
913+ }
914+
756915// List godoc
757916func (s * Server ) List (c * gin.Context ) {
758917 var groups []models.Group
0 commit comments