// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/update.go package collection import ( "context" "fmt" "time" "github.com/gocql/gocql" dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" "go.uber.org/zap" ) func (impl *collectionRepositoryImpl) Update(ctx context.Context, collection *dom_collection.Collection) error { if collection == nil { return fmt.Errorf("collection cannot be nil") } if !impl.isValidUUID(collection.ID) { return fmt.Errorf("collection ID is required") } impl.Logger.Info("starting collection update", zap.String("collection_id", collection.ID.String()), zap.Uint64("version", collection.Version), zap.Int("members_count", len(collection.Members))) // Get existing collection to compare changes existing, err := impl.Get(ctx, collection.ID) if err != nil { return fmt.Errorf("failed to get existing collection: %w", err) } if existing == nil { return fmt.Errorf("collection not found") } impl.Logger.Debug("loaded existing collection for comparison", zap.String("collection_id", existing.ID.String()), zap.Uint64("existing_version", existing.Version), zap.Int("existing_members_count", len(existing.Members))) // Update modified timestamp collection.ModifiedAt = time.Now() // Serialize complex fields ancestorIDsJSON, err := impl.serializeAncestorIDs(collection.AncestorIDs) if err != nil { return fmt.Errorf("failed to serialize ancestor IDs: %w", err) } encryptedKeyJSON, err := impl.serializeEncryptedCollectionKey(collection.EncryptedCollectionKey) if err != nil { return fmt.Errorf("failed to serialize encrypted collection key: %w", err) } tagsJSON, err := impl.serializeTags(collection.Tags) if err != nil { return fmt.Errorf("failed to serialize tags: %w", err) } batch := impl.Session.NewBatch(gocql.LoggedBatch) // // 1. Update main table // batch.Query(`UPDATE collections_by_id SET owner_id = ?, encrypted_name = ?, collection_type = ?, encrypted_collection_key = ?, encrypted_custom_icon = ?, parent_id = ?, ancestor_ids = ?, file_count = ?, tags = ?, created_at = ?, created_by_user_id = ?, modified_at = ?, modified_by_user_id = ?, version = ?, state = ?, tombstone_version = ?, tombstone_expiry = ? WHERE id = ?`, collection.OwnerID, collection.EncryptedName, collection.CollectionType, encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON, collection.FileCount, tagsJSON, collection.CreatedAt, collection.CreatedByUserID, collection.ModifiedAt, collection.ModifiedByUserID, collection.Version, collection.State, collection.TombstoneVersion, collection.TombstoneExpiry, collection.ID) // // 2. Update BOTH user access tables for owner // // Delete old owner entry from BOTH tables batch.Query(`DELETE FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id WHERE user_id = ? AND modified_at = ? AND collection_id = ?`, existing.OwnerID, existing.ModifiedAt, collection.ID) batch.Query(`DELETE FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id WHERE user_id = ? AND access_type = 'owner' AND modified_at = ? AND collection_id = ?`, existing.OwnerID, existing.ModifiedAt, collection.ID) // Insert new owner entry into BOTH tables batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id (user_id, modified_at, collection_id, access_type, permission_level, state) VALUES (?, ?, ?, 'owner', ?, ?)`, collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State) batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id (user_id, access_type, modified_at, collection_id, permission_level, state) VALUES (?, 'owner', ?, ?, ?, ?)`, collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State) // // 3. Update parent hierarchy if changed // oldParentID := existing.ParentID if !impl.isValidUUID(oldParentID) { oldParentID = impl.nullParentUUID() } newParentID := collection.ParentID if !impl.isValidUUID(newParentID) { newParentID = impl.nullParentUUID() } if oldParentID != newParentID || existing.OwnerID != collection.OwnerID { // Remove from old parent in original table batch.Query(`DELETE FROM collections_by_parent_id_with_asc_created_at_and_asc_collection_id WHERE parent_id = ? AND created_at = ? AND collection_id = ?`, oldParentID, collection.CreatedAt, collection.ID) // Add to new parent in original table batch.Query(`INSERT INTO collections_by_parent_id_with_asc_created_at_and_asc_collection_id (parent_id, created_at, collection_id, owner_id, state) VALUES (?, ?, ?, ?, ?)`, newParentID, collection.CreatedAt, collection.ID, collection.OwnerID, collection.State) // Remove from old parent+owner in composite table batch.Query(`DELETE FROM collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id WHERE parent_id = ? AND owner_id = ? AND created_at = ? AND collection_id = ?`, oldParentID, existing.OwnerID, collection.CreatedAt, collection.ID) // Add to new parent+owner in composite table batch.Query(`INSERT INTO collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id (parent_id, owner_id, created_at, collection_id, state) VALUES (?, ?, ?, ?, ?)`, newParentID, collection.OwnerID, collection.CreatedAt, collection.ID, collection.State) } else { // Update existing parent entry in original table batch.Query(`UPDATE collections_by_parent_id_with_asc_created_at_and_asc_collection_id SET owner_id = ?, state = ? WHERE parent_id = ? AND created_at = ? AND collection_id = ?`, collection.OwnerID, collection.State, newParentID, collection.CreatedAt, collection.ID) // Update existing parent entry in composite table batch.Query(`UPDATE collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id SET state = ? WHERE parent_id = ? AND owner_id = ? AND created_at = ? AND collection_id = ?`, collection.State, newParentID, collection.OwnerID, collection.CreatedAt, collection.ID) } // // 4. Update ancestor hierarchy // oldAncestorEntries := impl.buildAncestorDepthEntries(collection.ID, existing.AncestorIDs) for _, entry := range oldAncestorEntries { batch.Query(`DELETE FROM collections_by_ancestor_id_with_asc_depth_and_asc_collection_id WHERE ancestor_id = ? AND depth = ? AND collection_id = ?`, entry.AncestorID, entry.Depth, entry.CollectionID) } newAncestorEntries := impl.buildAncestorDepthEntries(collection.ID, collection.AncestorIDs) for _, entry := range newAncestorEntries { batch.Query(`INSERT INTO collections_by_ancestor_id_with_asc_depth_and_asc_collection_id (ancestor_id, depth, collection_id, state) VALUES (?, ?, ?, ?)`, entry.AncestorID, entry.Depth, entry.CollectionID, collection.State) } // // 5. Update denormalized collections_by_tag_id table // // Calculate tag changes oldTagsMap := make(map[gocql.UUID]bool) for _, tag := range existing.Tags { oldTagsMap[tag.ID] = true } newTagsMap := make(map[gocql.UUID]bool) for _, tag := range collection.Tags { newTagsMap[tag.ID] = true } // Delete entries for removed tags for tagID := range oldTagsMap { if !newTagsMap[tagID] { impl.Logger.Debug("removing collection from tag denormalized table", zap.String("collection_id", collection.ID.String()), zap.String("tag_id", tagID.String())) batch.Query(`DELETE FROM collections_by_tag_id WHERE tag_id = ? AND collection_id = ?`, tagID, collection.ID) } } // Insert/Update entries for current tags for _, tag := range collection.Tags { impl.Logger.Debug("updating collection in tag denormalized table", zap.String("collection_id", collection.ID.String()), zap.String("tag_id", tag.ID.String())) batch.Query(`INSERT INTO collections_by_tag_id (tag_id, collection_id, owner_id, encrypted_name, collection_type, encrypted_collection_key, encrypted_custom_icon, parent_id, ancestor_ids, file_count, tags, created_at, created_by_user_id, modified_at, modified_by_user_id, version, state, tombstone_version, tombstone_expiry, created_from_ip_address, modified_from_ip_address, ip_anonymized_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, tag.ID, collection.ID, collection.OwnerID, collection.EncryptedName, collection.CollectionType, encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON, collection.FileCount, tagsJSON, collection.CreatedAt, collection.CreatedByUserID, collection.ModifiedAt, collection.ModifiedByUserID, collection.Version, collection.State, collection.TombstoneVersion, collection.TombstoneExpiry, nil, nil, nil) // IP tracking fields not yet in domain model } // // 6. Handle members - FIXED: Delete members individually with composite key // impl.Logger.Info("processing member updates", zap.String("collection_id", collection.ID.String()), zap.Int("old_members", len(existing.Members)), zap.Int("new_members", len(collection.Members))) // Delete each existing member individually from the members table impl.Logger.Info("DEBUGGING: Deleting existing members individually from members table", zap.String("collection_id", collection.ID.String()), zap.Int("existing_members_count", len(existing.Members))) for _, oldMember := range existing.Members { impl.Logger.Debug("deleting member from members table", zap.String("collection_id", collection.ID.String()), zap.String("recipient_id", oldMember.RecipientID.String())) batch.Query(`DELETE FROM collection_members_by_collection_id_and_recipient_id WHERE collection_id = ? AND recipient_id = ?`, collection.ID, oldMember.RecipientID) } // Delete old member access entries from BOTH user access tables for _, oldMember := range existing.Members { impl.Logger.Debug("deleting old member access", zap.String("collection_id", collection.ID.String()), zap.String("recipient_id", oldMember.RecipientID.String()), zap.Time("old_modified_at", existing.ModifiedAt)) // Delete from original table batch.Query(`DELETE FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id WHERE user_id = ? AND modified_at = ? AND collection_id = ?`, oldMember.RecipientID, existing.ModifiedAt, collection.ID) // Delete from access-type-specific table batch.Query(`DELETE FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id WHERE user_id = ? AND access_type = 'member' AND modified_at = ? AND collection_id = ?`, oldMember.RecipientID, existing.ModifiedAt, collection.ID) } // Insert ALL new members into ALL tables impl.Logger.Info("DEBUGGING: About to insert members into tables", zap.String("collection_id", collection.ID.String()), zap.Int("total_members_to_insert", len(collection.Members))) for i, member := range collection.Members { impl.Logger.Info("inserting new member", zap.String("collection_id", collection.ID.String()), zap.Int("member_index", i), zap.String("recipient_id", member.RecipientID.String()), zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)), zap.String("permission_level", member.PermissionLevel), zap.Bool("is_inherited", member.IsInherited)) // Validate member data before insertion if !impl.isValidUUID(member.RecipientID) { return fmt.Errorf("invalid recipient ID for member %d", i) } if member.RecipientEmail == "" { return fmt.Errorf("recipient email is required for member %d", i) } if member.PermissionLevel == "" { return fmt.Errorf("permission level is required for member %d", i) } // FIXED: Only require encrypted collection key for non-owner members // The owner has access to the collection key through their master key isOwner := member.RecipientID == collection.OwnerID if !isOwner && len(member.EncryptedCollectionKey) == 0 { impl.Logger.Error("CRITICAL: encrypted collection key missing for shared member", zap.String("collection_id", collection.ID.String()), zap.Int("member_index", i), zap.String("recipient_id", member.RecipientID.String()), zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)), zap.String("owner_id", collection.OwnerID.String()), zap.Bool("is_owner", isOwner), zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey))) return fmt.Errorf("VALIDATION ERROR: encrypted collection key is required for shared member %d (recipient: %s, email: %s). This indicates a frontend bug or API misuse.", i, member.RecipientID.String(), validation.MaskEmail(member.RecipientEmail)) } // Additional validation for shared members if !isOwner && len(member.EncryptedCollectionKey) > 0 && len(member.EncryptedCollectionKey) < 32 { impl.Logger.Error("encrypted collection key appears invalid for shared member", zap.String("collection_id", collection.ID.String()), zap.Int("member_index", i), zap.String("recipient_id", member.RecipientID.String()), zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey))) return fmt.Errorf("encrypted collection key appears invalid for member %d (too short: %d bytes)", i, len(member.EncryptedCollectionKey)) } // Log key status for debugging impl.Logger.Debug("member key validation passed", zap.String("collection_id", collection.ID.String()), zap.Int("member_index", i), zap.String("recipient_id", member.RecipientID.String()), zap.Bool("is_owner", isOwner), zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey))) // Ensure member has an ID - but don't regenerate if it already exists if !impl.isValidUUID(member.ID) { member.ID = gocql.TimeUUID() impl.Logger.Debug("generated member ID", zap.String("member_id", member.ID.String()), zap.String("recipient_id", member.RecipientID.String())) } else { impl.Logger.Debug("using existing member ID", zap.String("member_id", member.ID.String()), zap.String("recipient_id", member.RecipientID.String())) } // Insert into normalized members table impl.Logger.Info("DEBUGGING: Inserting member into members table", zap.String("collection_id", collection.ID.String()), zap.Int("member_index", i), zap.String("member_id", member.ID.String()), zap.String("recipient_id", member.RecipientID.String()), zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)), zap.String("permission_level", member.PermissionLevel)) batch.Query(`INSERT INTO collection_members_by_collection_id_and_recipient_id (collection_id, recipient_id, member_id, recipient_email, granted_by_id, encrypted_collection_key, permission_level, created_at, is_inherited, inherited_from_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, collection.ID, member.RecipientID, member.ID, member.RecipientEmail, member.GrantedByID, member.EncryptedCollectionKey, member.PermissionLevel, member.CreatedAt, member.IsInherited, member.InheritedFromID) impl.Logger.Info("DEBUGGING: Added member insert query to batch", zap.String("collection_id", collection.ID.String()), zap.String("member_id", member.ID.String()), zap.String("recipient_id", member.RecipientID.String())) // Insert into BOTH user access tables impl.Logger.Info("🔍 UPDATE: Inserting member into access tables", zap.String("collection_id", collection.ID.String()), zap.String("recipient_id", member.RecipientID.String()), zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)), zap.String("permission_level", member.PermissionLevel), zap.String("state", collection.State)) // Original table batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id (user_id, modified_at, collection_id, access_type, permission_level, state) VALUES (?, ?, ?, 'member', ?, ?)`, member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State) // Access-type-specific table (THIS IS THE ONE USED FOR LISTING SHARED COLLECTIONS) impl.Logger.Info("🔍 UPDATE: Adding query to batch for access-type table", zap.String("table", "collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id"), zap.String("user_id", member.RecipientID.String()), zap.String("access_type", "member"), zap.String("collection_id", collection.ID.String()), zap.Time("modified_at", collection.ModifiedAt)) batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id (user_id, access_type, modified_at, collection_id, permission_level, state) VALUES (?, 'member', ?, ?, ?, ?)`, member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State) } // // 6. Execute the batch // impl.Logger.Info("executing batch update", zap.String("collection_id", collection.ID.String()), zap.Int("batch_size", batch.Size())) // Execute batch - ensures atomicity across all table updates impl.Logger.Info("DEBUGGING: About to execute batch with member inserts", zap.String("collection_id", collection.ID.String()), zap.Int("batch_size", batch.Size()), zap.Int("members_in_batch", len(collection.Members))) if err := impl.Session.ExecuteBatch(batch.WithContext(ctx)); err != nil { impl.Logger.Error("DEBUGGING: Batch execution failed", zap.String("collection_id", collection.ID.String()), zap.Int("batch_size", batch.Size()), zap.Error(err)) return fmt.Errorf("failed to update collection: %w", err) } impl.Logger.Info("DEBUGGING: Batch execution completed successfully", zap.String("collection_id", collection.ID.String()), zap.Int("batch_size", batch.Size())) // Log summary of what was written impl.Logger.Info("🔍 UPDATE: Batch executed successfully - Summary", zap.String("collection_id", collection.ID.String()), zap.Int("members_written", len(collection.Members))) for i, member := range collection.Members { impl.Logger.Info("🔍 UPDATE: Member written to database", zap.Int("index", i), zap.String("collection_id", collection.ID.String()), zap.String("recipient_id", member.RecipientID.String()), zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)), zap.String("permission_level", member.PermissionLevel)) } // Remove the immediate verification - Cassandra needs time to propagate // In production, we should trust the batch succeeded if no error was returned impl.Logger.Info("collection updated successfully in all tables", zap.String("collection_id", collection.ID.String()), zap.String("old_owner", existing.OwnerID.String()), zap.String("new_owner", collection.OwnerID.String()), zap.Int("old_member_count", len(existing.Members)), zap.Int("new_member_count", len(collection.Members))) return nil }