// monorepo/cloud/backend/internal/maplefile/service/collection/create.go package collection import ( "context" "fmt" "time" "go.uber.org/zap" "github.com/gocql/gocql" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto" dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) // CreateCollectionRequestDTO represents a Data Transfer Object (DTO) // used for transferring collection (folder or album) data between the local device and the cloud server. // This data is end-to-end encrypted (E2EE) on the local device before transmission. // The cloud server stores this encrypted data but cannot decrypt it. // On the local device, this data is decrypted for use and storage (not stored in this encrypted DTO format locally). // It can represent both root collections and embedded subcollections. type CreateCollectionRequestDTO struct { ID gocql.UUID `bson:"_id" json:"id"` OwnerID gocql.UUID `bson:"owner_id" json:"owner_id"` EncryptedName string `bson:"encrypted_name" json:"encrypted_name"` EncryptedCustomIcon string `bson:"encrypted_custom_icon" json:"encrypted_custom_icon"` CollectionType string `bson:"collection_type" json:"collection_type"` EncryptedCollectionKey *crypto.EncryptedCollectionKey `bson:"encrypted_collection_key" json:"encrypted_collection_key"` Members []*CollectionMembershipDTO `bson:"members" json:"members"` ParentID gocql.UUID `bson:"parent_id,omitempty" json:"parent_id,omitempty"` AncestorIDs []gocql.UUID `bson:"ancestor_ids,omitempty" json:"ancestor_ids,omitempty"` TagIDs []gocql.UUID `bson:"tag_ids,omitempty" json:"tag_ids,omitempty"` // Tag IDs to embed in collection Children []*CreateCollectionRequestDTO `bson:"children,omitempty" json:"children,omitempty"` CreatedAt time.Time `bson:"created_at" json:"created_at"` CreatedByUserID gocql.UUID `json:"created_by_user_id"` ModifiedAt time.Time `bson:"modified_at" json:"modified_at"` ModifiedByUserID gocql.UUID `json:"modified_by_user_id"` } type CollectionMembershipDTO struct { ID gocql.UUID `bson:"_id" json:"id"` CollectionID gocql.UUID `bson:"collection_id" json:"collection_id"` RecipientID gocql.UUID `bson:"recipient_id" json:"recipient_id"` RecipientEmail string `bson:"recipient_email" json:"recipient_email"` GrantedByID gocql.UUID `bson:"granted_by_id" json:"granted_by_id"` EncryptedCollectionKey []byte `bson:"encrypted_collection_key" json:"encrypted_collection_key"` PermissionLevel string `bson:"permission_level" json:"permission_level"` CreatedAt time.Time `bson:"created_at" json:"created_at"` IsInherited bool `bson:"is_inherited" json:"is_inherited"` InheritedFromID gocql.UUID `bson:"inherited_from_id,omitempty" json:"inherited_from_id,omitempty"` } type CollectionResponseDTO struct { ID gocql.UUID `json:"id"` OwnerID gocql.UUID `json:"owner_id"` OwnerEmail string `json:"owner_email"` EncryptedName string `json:"encrypted_name"` EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty"` CollectionType string `json:"collection_type"` ParentID gocql.UUID `json:"parent_id,omitempty"` AncestorIDs []gocql.UUID `json:"ancestor_ids,omitempty"` Tags []tag.EmbeddedTag `json:"tags,omitempty"` EncryptedCollectionKey *crypto.EncryptedCollectionKey `json:"encrypted_collection_key,omitempty"` Children []*CollectionResponseDTO `json:"children,omitempty"` CreatedAt time.Time `json:"created_at"` ModifiedAt time.Time `json:"modified_at"` Members []MembershipResponseDTO `json:"members"` FileCount int `json:"file_count"` Version uint64 `json:"version"` } type MembershipResponseDTO struct { ID gocql.UUID `bson:"_id" json:"id"` CollectionID gocql.UUID `bson:"collection_id" json:"collection_id"` // ID of the collection (redundant but helpful for queries) RecipientID gocql.UUID `bson:"recipient_id" json:"recipient_id"` // User receiving access RecipientEmail string `bson:"recipient_email" json:"recipient_email"` // Email for display purposes GrantedByID gocql.UUID `bson:"granted_by_id" json:"granted_by_id"` // User who shared the collection // Collection key encrypted with recipient's public key using box_seal. This matches the box_seal format which doesn't need a separate nonce. EncryptedCollectionKey []byte `bson:"encrypted_collection_key" json:"encrypted_collection_key"` // Access details PermissionLevel string `bson:"permission_level" json:"permission_level"` CreatedAt time.Time `bson:"created_at" json:"created_at"` // Sharing origin tracking IsInherited bool `bson:"is_inherited" json:"is_inherited"` // Tracks whether access was granted directly or inherited from a parent InheritedFromID gocql.UUID `bson:"inherited_from_id,omitempty" json:"inherited_from_id,omitempty"` // InheritedFromID identifies which parent collection granted this access } type CreateCollectionService interface { Execute(ctx context.Context, req *CreateCollectionRequestDTO) (*CollectionResponseDTO, error) } type createCollectionServiceImpl struct { config *config.Configuration logger *zap.Logger userGetByIDUseCase uc_user.UserGetByIDUseCase repo dom_collection.CollectionRepository tagRepo tag.Repository } func NewCreateCollectionService( config *config.Configuration, logger *zap.Logger, userGetByIDUseCase uc_user.UserGetByIDUseCase, repo dom_collection.CollectionRepository, tagRepo tag.Repository, ) CreateCollectionService { logger = logger.Named("CreateCollectionService") return &createCollectionServiceImpl{ config: config, logger: logger, userGetByIDUseCase: userGetByIDUseCase, repo: repo, tagRepo: tagRepo, } } func (svc *createCollectionServiceImpl) Execute(ctx context.Context, req *CreateCollectionRequestDTO) (*CollectionResponseDTO, error) { // // STEP 1: Validation // if req == nil { svc.logger.Warn("Failed validation with nil request") return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Collection details are required") } e := make(map[string]string) if req.ID.String() == "" { e["encrypted_name"] = "Client-side generated ID is required" } if req.EncryptedName == "" { e["encrypted_name"] = "Collection name is required" } if req.CollectionType == "" { e["collection_type"] = "Collection type is required" } else if req.CollectionType != dom_collection.CollectionTypeFolder && req.CollectionType != dom_collection.CollectionTypeAlbum { e["collection_type"] = "Collection type must be either 'folder' or 'album'" } // Check pointer and then content if req.EncryptedCollectionKey == nil || req.EncryptedCollectionKey.Ciphertext == nil || len(req.EncryptedCollectionKey.Ciphertext) == 0 { e["encrypted_collection_key"] = "Encrypted collection key ciphertext is required" } if req.EncryptedCollectionKey == nil || req.EncryptedCollectionKey.Nonce == nil || len(req.EncryptedCollectionKey.Nonce) == 0 { e["encrypted_collection_key"] = "Encrypted collection key nonce is required" } if len(e) != 0 { svc.logger.Warn("Failed validation", zap.Any("error", e)) return nil, httperror.NewForBadRequest(&e) } // // STEP 2: Get user ID from context // userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID) if !ok { svc.logger.Error("Failed getting user ID from context") return nil, httperror.NewForInternalServerErrorWithSingleField("message", "Authentication context error") } federateduser, err := svc.userGetByIDUseCase.Execute(ctx, userID) if err != nil { return nil, fmt.Errorf("Failed getting user from database: %v", err) } if federateduser == nil { return nil, fmt.Errorf("User does not exist for user id: %v", userID.String()) } // // STEP 3: Create collection object by mapping DTO and applying server-side logic // now := time.Now() // Map all fields from the request DTO to the domain object. // This copies client-provided values including potential ID, OwnerID, timestamps, etc. collection := mapCollectionDTOToDomain(req, userID, now) // Apply server-side mandatory fields/overrides for the top-level collection. // These values are managed by the backend regardless of what the client provides in the DTO. // This ensures data integrity and reflects the server's perspective of the creation event. collection.ID = gocql.TimeUUID() // Always generate a new ID on the server for a new creation collection.OwnerID = userID // The authenticated user is the authoritative owner collection.CreatedAt = now // Server timestamp for creation collection.ModifiedAt = now // Server timestamp for modification collection.CreatedByUserID = userID // The authenticated user is the creator collection.ModifiedByUserID = userID // The authenticated user is the initial modifier collection.Version = 1 // Collection creation **always** starts mutation version at 1. collection.State = dom_collection.CollectionStateActive // Collection creation **always** starts in active state. // Ensure owner membership exists with Admin permissions. // Check if the owner is already present in the members list copied from the DTO. ownerAlreadyMember := false for i := range collection.Members { // Iterate by index to allow modification if needed if collection.Members[i].RecipientID == userID { // Owner is found. Ensure they have Admin permission and correct granted_by/is_inherited status. collection.Members[i].RecipientEmail = federateduser.Email collection.Members[i].PermissionLevel = dom_collection.CollectionPermissionAdmin collection.Members[i].GrantedByID = userID collection.Members[i].IsInherited = false // NOTE: We intentionally do NOT set EncryptedCollectionKey here for the owner // The owner accesses the collection key through their master key, not through // the encrypted member key. This is validated in the repository layer. collection.Members[i].EncryptedCollectionKey = nil // Optionally update membership CreatedAt here if server should control it, otherwise keep DTO value. // collection.Members[i].CreatedAt = now ownerAlreadyMember = true svc.logger.Debug("✅ Owner membership updated with Admin permissions (no encrypted key needed)") break } } // If owner is not in the members list, add their mandatory membership. if !ownerAlreadyMember { svc.logger.Debug("☑️ Owner is not in the members list, add their mandatory membership now") ownerMembership := dom_collection.CollectionMembership{ ID: gocql.TimeUUID(), // Unique ID for this specific membership record RecipientID: userID, RecipientEmail: federateduser.Email, CollectionID: collection.ID, // Link to the newly created collection ID PermissionLevel: dom_collection.CollectionPermissionAdmin, // Owner must have Admin GrantedByID: userID, // Owner implicitly grants themselves permission IsInherited: false, // Owner membership is never inherited CreatedAt: now, // Server timestamp for membership creation // NOTE: EncryptedCollectionKey is intentionally nil for owner memberships // The owner has access to the collection key through their master key // This is validated in the repository layer which allows nil encrypted keys for owners EncryptedCollectionKey: nil, // InheritedFromID is nil for direct membership. } // Append the mandatory owner membership. If req.Members was empty, this initializes the slice. collection.Members = append(collection.Members, ownerMembership) svc.logger.Debug("✅ Owner membership added with Admin permissions (no encrypted key needed)") } svc.logger.Debug("🔍 Collection debugging info", zap.String("collectionID", collection.ID.String()), zap.String("collectionOwnerID", collection.OwnerID.String()), zap.String("currentUserID", userID.String()), zap.Int("totalMembers", len(collection.Members)), zap.String("encryptedName", collection.EncryptedName)) for i, memberDTO := range collection.Members { isOwner := memberDTO.RecipientID == collection.OwnerID svc.logger.Debug("🔍 Cloud collection member DTO", zap.Int("memberIndex", i), zap.String("memberID", memberDTO.ID.String()), zap.String("recipientID", memberDTO.RecipientID.String()), zap.String("recipientEmail", validation.MaskEmail(memberDTO.RecipientEmail)), zap.String("permissionLevel", memberDTO.PermissionLevel), zap.Bool("isInherited", memberDTO.IsInherited), zap.Bool("isOwner", isOwner), zap.Int("encryptedKeyLength", len(memberDTO.EncryptedCollectionKey))) } // ENHANCED DEBUGGING: Log current user info for comparison svc.logger.Debug("🔍 Current user info for comparison", zap.String("currentUserID", federateduser.ID.String()), zap.String("currentUserEmail", validation.MaskEmail(federateduser.Email)), zap.String("currentUserName", federateduser.Name)) // Note: Fields like ParentID, AncestorIDs, EncryptedCollectionKey, // EncryptedName, CollectionType, and recursively mapped Children are copied directly from the DTO // by the mapCollectionDTOToDomain function before server overrides. This fulfills the // prompt's requirement to copy these fields from the DTO. // // STEP 3.5: Look up and embed tags if TagIDs were provided // if len(req.TagIDs) > 0 { svc.logger.Debug("🏷️ Looking up tags to embed in collection", zap.Int("tagCount", len(req.TagIDs))) var embeddedTags []tag.EmbeddedTag for _, tagID := range req.TagIDs { tagObj, err := svc.tagRepo.GetByID(ctx, tagID) if err != nil { svc.logger.Warn("Failed to get tag for embedding, skipping", zap.String("tagID", tagID.String()), zap.Error(err)) continue } if tagObj == nil { svc.logger.Warn("Tag not found for embedding, skipping", zap.String("tagID", tagID.String())) continue } // Convert Tag to EmbeddedTag embedded := tagObj.ToEmbeddedTag() if embedded != nil { embeddedTags = append(embeddedTags, *embedded) svc.logger.Debug("🏷️ Tag embedded successfully", zap.String("tagID", tagID.String())) } } collection.Tags = embeddedTags svc.logger.Debug("🏷️ Tags embedded in collection", zap.Int("embeddedCount", len(embeddedTags))) } // // STEP 4: Create collection in repository // if err := svc.repo.Create(ctx, collection); err != nil { svc.logger.Error("Failed to create collection", zap.Any("error", err), zap.Any("owner_id", collection.OwnerID), zap.String("name", collection.EncryptedName)) return nil, err } // // STEP 5: Map domain model to response DTO // // The mapCollectionToDTO helper is used here to convert the created domain object back // into the response DTO format, potentially excluding sensitive fields like keys // or specific membership details not meant for the general response. response := mapCollectionToDTO(collection, 0, federateduser.Email) svc.logger.Debug("Collection created successfully", zap.Any("collection_id", collection.ID), zap.Any("owner_id", collection.OwnerID)) return response, nil }