Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,61 @@
// monorepo/cloud/maplefile-backend/internal/repo/collection/anonymize_collection_ips.go
package collection
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// AnonymizeCollectionIPsByOwner immediately anonymizes all IP addresses for collections owned by a specific user
// Used for GDPR right-to-be-forgotten implementation
func (impl *collectionRepositoryImpl) AnonymizeCollectionIPsByOwner(ctx context.Context, ownerID gocql.UUID) (int, error) {
impl.Logger.Info("Anonymizing IPs for collections owned by user (GDPR mode)",
zap.String("owner_id", ownerID.String()))
count := 0
// Query all collections owned by this user
query := `SELECT id FROM maplefile.collections_by_id WHERE owner_id = ? ALLOW FILTERING`
iter := impl.Session.Query(query, ownerID).WithContext(ctx).Iter()
var collectionID gocql.UUID
var collectionIDs []gocql.UUID
// Collect all collection IDs first
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("Error querying collections by owner", zap.Error(err))
return count, err
}
// Anonymize IPs for each collection
for _, colID := range collectionIDs {
updateQuery := `
UPDATE maplefile.collections_by_id
SET created_from_ip_address = '0.0.0.0',
modified_from_ip_address = '0.0.0.0',
ip_anonymized_at = ?
WHERE id = ?
`
if err := impl.Session.Query(updateQuery, time.Now(), colID).WithContext(ctx).Exec(); err != nil {
impl.Logger.Error("Failed to anonymize collection IPs",
zap.String("collection_id", colID.String()),
zap.Error(err))
continue // Best-effort: continue with next collection
}
count++
}
impl.Logger.Info("✅ Successfully anonymized collection IPs",
zap.String("owner_id", ownerID.String()),
zap.Int("collections_anonymized", count))
return count, nil
}

View file

@ -0,0 +1,76 @@
// monorepo/cloud/maplefile-backend/internal/repo/collection/anonymize_old_ips.go
package collection
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// AnonymizeOldIPs anonymizes IP addresses in collection tables older than the cutoff date
func (impl *collectionRepositoryImpl) AnonymizeOldIPs(ctx context.Context, cutoffDate time.Time) (int, error) {
totalAnonymized := 0
// Anonymize collections_by_id table (primary table)
count, err := impl.anonymizeCollectionsById(ctx, cutoffDate)
if err != nil {
impl.Logger.Error("Failed to anonymize collections_by_id",
zap.Error(err),
zap.Time("cutoff_date", cutoffDate))
return totalAnonymized, err
}
totalAnonymized += count
impl.Logger.Info("IP anonymization completed for collection tables",
zap.Int("total_anonymized", totalAnonymized),
zap.Time("cutoff_date", cutoffDate))
return totalAnonymized, nil
}
// anonymizeCollectionsById processes the collections_by_id table
func (impl *collectionRepositoryImpl) anonymizeCollectionsById(ctx context.Context, cutoffDate time.Time) (int, error) {
count := 0
// Query all collections (efficient primary key scan, no ALLOW FILTERING)
query := `SELECT id, created_at, ip_anonymized_at FROM maplefile.collections_by_id`
iter := impl.Session.Query(query).WithContext(ctx).Iter()
var id gocql.UUID
var createdAt time.Time
var ipAnonymizedAt *time.Time
for iter.Scan(&id, &createdAt, &ipAnonymizedAt) {
// Filter in application code: older than cutoff AND not yet anonymized
if createdAt.Before(cutoffDate) && ipAnonymizedAt == nil {
// Update the record to anonymize IPs
updateQuery := `
UPDATE maplefile.collections_by_id
SET created_from_ip_address = '',
modified_from_ip_address = '',
ip_anonymized_at = ?
WHERE id = ?
`
if err := impl.Session.Query(updateQuery, time.Now(), id).WithContext(ctx).Exec(); err != nil {
impl.Logger.Error("Failed to anonymize collection record",
zap.String("collection_id", id.String()),
zap.Error(err))
continue
}
count++
}
}
if err := iter.Close(); err != nil {
impl.Logger.Error("Error during collections_by_id iteration", zap.Error(err))
return count, err
}
impl.Logger.Debug("Anonymized collections_by_id table",
zap.Int("count", count),
zap.Time("cutoff_date", cutoffDate))
return count, nil
}

View file

@ -0,0 +1,34 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/archive.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
func (impl *collectionRepositoryImpl) Archive(ctx context.Context, id gocql.UUID) error {
collection, err := impl.Get(ctx, id)
if err != nil {
return fmt.Errorf("failed to get collection for archive: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
// Validate state transition
if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateArchived); err != nil {
return fmt.Errorf("invalid state transition: %w", err)
}
// Update collection state
collection.State = dom_collection.CollectionStateArchived
collection.ModifiedAt = time.Now()
collection.Version++
return impl.Update(ctx, collection)
}

View file

@ -0,0 +1,160 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/check.go
package collection
import (
"context"
"fmt"
"github.com/gocql/gocql"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
func (impl *collectionRepositoryImpl) CheckIfExistsByID(ctx context.Context, id gocql.UUID) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM collections_by_id WHERE id = ?`
if err := impl.Session.Query(query, id).WithContext(ctx).Scan(&count); err != nil {
return false, fmt.Errorf("failed to check collection existence: %w", err)
}
return count > 0, nil
}
// IsCollectionOwner demonstrates the memory-filtering approach for better performance
// Instead of forcing Cassandra to scan with ALLOW FILTERING, we query efficiently and filter in memory
func (impl *collectionRepositoryImpl) IsCollectionOwner(ctx context.Context, collectionID, userID gocql.UUID) (bool, error) {
// Strategy: Use the compound partition key table to efficiently check ownership
// This query is fast because both user_id and access_type are part of the partition key
var collectionExists gocql.UUID
query := `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'owner' AND collection_id = ? LIMIT 1 ALLOW FILTERING`
err := impl.Session.Query(query, userID, collectionID).WithContext(ctx).Scan(&collectionExists)
if err != nil {
if err == gocql.ErrNotFound {
return false, nil
}
return false, fmt.Errorf("failed to check ownership: %w", err)
}
// If we got a result, the user is an owner of this collection
return true, nil
}
// Alternative implementation using the memory-filtering approach
// This demonstrates a different strategy when you can't avoid some filtering
func (impl *collectionRepositoryImpl) IsCollectionOwnerAlternative(ctx context.Context, collectionID, userID gocql.UUID) (bool, error) {
// Memory-filtering approach: Get all collections for this user, filter for the specific collection
// This is efficient when users don't have thousands of collections
query := `SELECT collection_id, access_type FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ?`
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
var currentCollectionID gocql.UUID
var accessType string
for iter.Scan(&currentCollectionID, &accessType) {
// Check if this is the collection we're looking for and if the user is the owner
if currentCollectionID == collectionID && accessType == "owner" {
iter.Close()
return true, nil
}
}
if err := iter.Close(); err != nil {
return false, fmt.Errorf("failed to check ownership: %w", err)
}
return false, nil
}
// CheckAccess uses the efficient compound partition key approach
func (impl *collectionRepositoryImpl) CheckAccess(ctx context.Context, collectionID, userID gocql.UUID, requiredPermission string) (bool, error) {
// First check if user is owner (owners have all permissions)
isOwner, err := impl.IsCollectionOwner(ctx, collectionID, userID)
if err != nil {
return false, fmt.Errorf("failed to check ownership: %w", err)
}
if isOwner {
return true, nil // Owners have all permissions
}
// Check if user is a member with sufficient permissions
var permissionLevel string
query := `SELECT permission_level FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'member' AND collection_id = ? LIMIT 1 ALLOW FILTERING`
err = impl.Session.Query(query, userID, collectionID).WithContext(ctx).Scan(&permissionLevel)
if err != nil {
if err == gocql.ErrNotFound {
return false, nil // No access
}
return false, fmt.Errorf("failed to check member access: %w", err)
}
// Check if user's permission level meets requirement
return impl.hasPermission(permissionLevel, requiredPermission), nil
}
// GetUserPermissionLevel efficiently determines a user's permission level for a collection
func (impl *collectionRepositoryImpl) GetUserPermissionLevel(ctx context.Context, collectionID, userID gocql.UUID) (string, error) {
// Check ownership first using the efficient compound key table
isOwner, err := impl.IsCollectionOwner(ctx, collectionID, userID)
if err != nil {
return "", fmt.Errorf("failed to check ownership: %w", err)
}
if isOwner {
return dom_collection.CollectionPermissionAdmin, nil
}
// Check member permissions
var permissionLevel string
query := `SELECT permission_level FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'member' AND collection_id = ? LIMIT 1 ALLOW FILTERING`
err = impl.Session.Query(query, userID, collectionID).WithContext(ctx).Scan(&permissionLevel)
if err != nil {
if err == gocql.ErrNotFound {
return "", nil // No access
}
return "", fmt.Errorf("failed to get permission level: %w", err)
}
return permissionLevel, nil
}
// Demonstration of a completely ALLOW FILTERING-free approach using direct collection lookup
// This approach queries the main collection table and checks ownership directly
func (impl *collectionRepositoryImpl) CheckAccessByCollectionLookup(ctx context.Context, collectionID, userID gocql.UUID, requiredPermission string) (bool, error) {
// Strategy: Get the collection directly and check ownership/membership from the collection object
collection, err := impl.Get(ctx, collectionID)
if err != nil {
return false, fmt.Errorf("failed to get collection: %w", err)
}
if collection == nil {
return false, nil // Collection doesn't exist
}
// Check if user is the owner
if collection.OwnerID == userID {
return true, nil // Owners have all permissions
}
// Check if user is a member with sufficient permissions
for _, member := range collection.Members {
if member.RecipientID == userID {
return impl.hasPermission(member.PermissionLevel, requiredPermission), nil
}
}
return false, nil // User has no access
}

View file

@ -0,0 +1,191 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/collectionsync.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
// GetCollectionSyncData uses the general table when you need all collections regardless of access type
func (impl *collectionRepositoryImpl) GetCollectionSyncData(ctx context.Context, userID gocql.UUID, cursor *dom_collection.CollectionSyncCursor, limit int64) (*dom_collection.CollectionSyncResponse, error) {
var query string
var args []any
// Key Insight: We can query all collections for a user efficiently because user_id is the partition key
// We select access_type in the result set so we can filter or categorize after retrieval
if cursor == nil {
query = `SELECT collection_id, modified_at, access_type FROM
collections_by_user_id_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? LIMIT ?`
args = []any{userID, limit}
} else {
query = `SELECT collection_id, modified_at, access_type FROM
collections_by_user_id_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND (modified_at, collection_id) > (?, ?) LIMIT ?`
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
}
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
var syncItems []dom_collection.CollectionSyncItem
var lastModified time.Time
var lastID gocql.UUID
// Critical Fix: We must scan all three selected columns
var collectionID gocql.UUID
var modifiedAt time.Time
var accessType string
for iter.Scan(&collectionID, &modifiedAt, &accessType) {
// Get minimal sync data for this collection
syncItem, err := impl.getCollectionSyncItem(ctx, collectionID)
if err != nil {
impl.Logger.Warn("failed to get sync item for collection",
zap.String("collection_id", collectionID.String()),
zap.String("access_type", accessType),
zap.Error(err))
continue
}
if syncItem != nil {
syncItems = append(syncItems, *syncItem)
lastModified = modifiedAt
lastID = collectionID
}
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get collection sync data: %w", err)
}
// Prepare response
response := &dom_collection.CollectionSyncResponse{
Collections: syncItems,
HasMore: len(syncItems) == int(limit),
}
// Set next cursor if there are more results
if response.HasMore {
response.NextCursor = &dom_collection.CollectionSyncCursor{
LastModified: lastModified,
LastID: lastID,
}
}
return response, nil
}
// GetCollectionSyncData uses the access-type-specific table for optimal performance
// This method demonstrates the power of compound partition keys in Cassandra
func (impl *collectionRepositoryImpl) GetCollectionSyncDataByAccessType(ctx context.Context, userID gocql.UUID, cursor *dom_collection.CollectionSyncCursor, limit int64, accessType string) (*dom_collection.CollectionSyncResponse, error) {
var query string
var args []any
// Key Insight: With the compound partition key (user_id, access_type), this query is lightning fast
// Cassandra can directly access the specific partition without any filtering or scanning
if cursor == nil {
query = `SELECT collection_id, modified_at FROM
collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'owner' LIMIT ?`
args = []any{userID, limit}
} else {
query = `SELECT collection_id, modified_at 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, collection_id) > (?, ?) LIMIT ?`
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
}
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
var syncItems []dom_collection.CollectionSyncItem
var lastModified time.Time
var lastID gocql.UUID
var collectionID gocql.UUID
var modifiedAt time.Time
for iter.Scan(&collectionID, &modifiedAt) {
// Get minimal sync data for this collection
syncItem, err := impl.getCollectionSyncItem(ctx, collectionID)
if err != nil {
impl.Logger.Warn("failed to get sync item for collection",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
continue
}
if syncItem != nil {
syncItems = append(syncItems, *syncItem)
lastModified = modifiedAt
lastID = collectionID
}
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get collection sync data: %w", err)
}
// Prepare response
response := &dom_collection.CollectionSyncResponse{
Collections: syncItems,
HasMore: len(syncItems) == int(limit),
}
// Set next cursor if there are more results
if response.HasMore {
response.NextCursor = &dom_collection.CollectionSyncCursor{
LastModified: lastModified,
LastID: lastID,
}
}
return response, nil
}
// Helper method to get minimal sync data for a collection
func (impl *collectionRepositoryImpl) getCollectionSyncItem(ctx context.Context, collectionID gocql.UUID) (*dom_collection.CollectionSyncItem, error) {
var (
id gocql.UUID
version, tombstoneVersion uint64
modifiedAt, tombstoneExpiry time.Time
state string
parentID gocql.UUID
encryptedCustomIcon string
)
query := `SELECT id, version, modified_at, state, parent_id, tombstone_version, tombstone_expiry, encrypted_custom_icon
FROM collections_by_id WHERE id = ?`
err := impl.Session.Query(query, collectionID).WithContext(ctx).Scan(
&id, &version, &modifiedAt, &state, &parentID, &tombstoneVersion, &tombstoneExpiry, &encryptedCustomIcon)
if err != nil {
if err == gocql.ErrNotFound {
return nil, nil
}
return nil, fmt.Errorf("failed to get collection sync item: %w", err)
}
syncItem := &dom_collection.CollectionSyncItem{
ID: id,
Version: version,
ModifiedAt: modifiedAt,
State: state,
TombstoneVersion: tombstoneVersion,
TombstoneExpiry: tombstoneExpiry,
EncryptedCustomIcon: encryptedCustomIcon,
}
// Only include ParentID if it's valid
if impl.isValidUUID(parentID) {
syncItem.ParentID = &parentID
}
return syncItem, nil
}

View file

@ -0,0 +1,334 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/count.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
// CountOwnedCollections counts all collections (folders + albums) owned by the user
func (impl *collectionRepositoryImpl) CountOwnedCollections(ctx context.Context, userID gocql.UUID) (int, error) {
return impl.countCollectionsByUserAndType(ctx, userID, "owner", "")
}
// CountSharedCollections counts all collections (folders + albums) shared with the user
func (impl *collectionRepositoryImpl) CountSharedCollections(ctx context.Context, userID gocql.UUID) (int, error) {
return impl.countCollectionsByUserAndType(ctx, userID, "member", "")
}
// CountOwnedFolders counts only folders owned by the user
func (impl *collectionRepositoryImpl) CountOwnedFolders(ctx context.Context, userID gocql.UUID) (int, error) {
return impl.countCollectionsByUserAndType(ctx, userID, "owner", dom_collection.CollectionTypeFolder)
}
// CountSharedFolders counts only folders shared with the user
func (impl *collectionRepositoryImpl) CountSharedFolders(ctx context.Context, userID gocql.UUID) (int, error) {
return impl.countCollectionsByUserAndType(ctx, userID, "member", dom_collection.CollectionTypeFolder)
}
// countCollectionsByUserAndType is a helper method that efficiently counts collections
// filterType: empty string for all types, or specific type like "folder"
func (impl *collectionRepositoryImpl) countCollectionsByUserAndType(ctx context.Context, userID gocql.UUID, accessType, filterType string) (int, error) {
// Use the access-type-specific table for efficient querying
query := `SELECT collection_id FROM maplefile.collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = ?`
impl.Logger.Debug("Starting collection count query",
zap.String("user_id", userID.String()),
zap.String("access_type", accessType),
zap.String("filter_type", filterType))
iter := impl.Session.Query(query, userID, accessType).WithContext(ctx).Iter()
count := 0
totalScanned := 0
var collectionID gocql.UUID
var debugCollectionIDs []string
// Iterate through results and count based on criteria
for iter.Scan(&collectionID) {
totalScanned++
debugCollectionIDs = append(debugCollectionIDs, collectionID.String())
impl.Logger.Debug("Processing collection for count",
zap.String("collection_id", collectionID.String()),
zap.Int("total_scanned", totalScanned),
zap.String("access_type", accessType))
// Get the collection to check state and type
collection, err := impl.getBaseCollection(ctx, collectionID)
if err != nil {
impl.Logger.Warn("failed to get collection for counting",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
continue
}
if collection == nil {
impl.Logger.Warn("collection not found for counting",
zap.String("collection_id", collectionID.String()))
continue
}
impl.Logger.Debug("Collection details for counting",
zap.String("collection_id", collectionID.String()),
zap.String("state", collection.State),
zap.String("collection_type", collection.CollectionType),
zap.String("owner_id", collection.OwnerID.String()),
zap.String("querying_user_id", userID.String()),
zap.String("access_type", accessType),
zap.String("required_filter_type", filterType))
// Only count active collections
if collection.State != dom_collection.CollectionStateActive {
impl.Logger.Debug("Skipping collection due to non-active state",
zap.String("collection_id", collectionID.String()),
zap.String("state", collection.State))
continue
}
// Filter by type if specified
if filterType != "" && collection.CollectionType != filterType {
impl.Logger.Debug("Skipping collection due to type filter",
zap.String("collection_id", collectionID.String()),
zap.String("collection_type", collection.CollectionType),
zap.String("required_type", filterType))
continue
}
count++
impl.Logger.Info("Collection counted",
zap.String("collection_id", collectionID.String()),
zap.String("access_type", accessType),
zap.String("owner_id", collection.OwnerID.String()),
zap.String("querying_user_id", userID.String()),
zap.Bool("is_owner", collection.OwnerID == userID),
zap.Int("current_count", count))
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to count collections",
zap.String("user_id", userID.String()),
zap.String("access_type", accessType),
zap.String("filter_type", filterType),
zap.Error(err))
return 0, fmt.Errorf("failed to count collections: %w", err)
}
impl.Logger.Info("Collection count completed",
zap.String("user_id", userID.String()),
zap.String("access_type", accessType),
zap.String("filter_type", filterType),
zap.Int("final_count", count),
zap.Int("total_scanned", totalScanned),
zap.Strings("scanned_collection_ids", debugCollectionIDs))
return count, nil
}
// FIXED DEBUG: Query both access types separately to avoid ALLOW FILTERING
func (impl *collectionRepositoryImpl) DebugCollectionRecords(ctx context.Context, userID gocql.UUID) error {
impl.Logger.Info("=== DEBUG: Checking OWNER records ===")
// Check owner records
ownerQuery := `SELECT user_id, access_type, modified_at, collection_id, permission_level, state
FROM maplefile.collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = ?`
ownerIter := impl.Session.Query(ownerQuery, userID, "owner").WithContext(ctx).Iter()
var (
resultUserID gocql.UUID
accessType string
modifiedAt time.Time
collectionID gocql.UUID
permissionLevel string
state string
)
ownerCount := 0
for ownerIter.Scan(&resultUserID, &accessType, &modifiedAt, &collectionID, &permissionLevel, &state) {
ownerCount++
impl.Logger.Info("DEBUG: Found OWNER record",
zap.Int("record_number", ownerCount),
zap.String("user_id", resultUserID.String()),
zap.String("access_type", accessType),
zap.Time("modified_at", modifiedAt),
zap.String("collection_id", collectionID.String()),
zap.String("permission_level", permissionLevel),
zap.String("state", state))
}
ownerIter.Close()
impl.Logger.Info("=== DEBUG: Checking MEMBER records ===")
// Check member records
memberIter := impl.Session.Query(ownerQuery, userID, "member").WithContext(ctx).Iter()
memberCount := 0
for memberIter.Scan(&resultUserID, &accessType, &modifiedAt, &collectionID, &permissionLevel, &state) {
memberCount++
impl.Logger.Info("DEBUG: Found MEMBER record",
zap.Int("record_number", memberCount),
zap.String("user_id", resultUserID.String()),
zap.String("access_type", accessType),
zap.Time("modified_at", modifiedAt),
zap.String("collection_id", collectionID.String()),
zap.String("permission_level", permissionLevel),
zap.String("state", state))
}
memberIter.Close()
impl.Logger.Info("DEBUG: Total records summary",
zap.String("user_id", userID.String()),
zap.Int("owner_records", ownerCount),
zap.Int("member_records", memberCount),
zap.Int("total_records", ownerCount+memberCount))
return nil
}
// Alternative optimized implementation for when you need both owned and shared counts
// This reduces database round trips by querying once and separating in memory
func (impl *collectionRepositoryImpl) countCollectionsSummary(ctx context.Context, userID gocql.UUID, filterType string) (ownedCount, sharedCount int, err error) {
// Query all collections for the user using the general table
query := `SELECT collection_id, access_type FROM maplefile.collections_by_user_id_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ?`
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
var collectionID gocql.UUID
var accessType string
for iter.Scan(&collectionID, &accessType) {
// Get the collection to check state and type
collection, getErr := impl.getBaseCollection(ctx, collectionID)
if getErr != nil {
impl.Logger.Warn("failed to get collection for counting summary",
zap.String("collection_id", collectionID.String()),
zap.Error(getErr))
continue
}
if collection == nil {
continue
}
// Only count active collections
if collection.State != dom_collection.CollectionStateActive {
continue
}
// Filter by type if specified
if filterType != "" && collection.CollectionType != filterType {
continue
}
// Count based on access type
switch accessType {
case "owner":
ownedCount++
case "member":
sharedCount++
}
}
if err = iter.Close(); err != nil {
impl.Logger.Error("failed to count collections summary",
zap.String("user_id", userID.String()),
zap.String("filter_type", filterType),
zap.Error(err))
return 0, 0, fmt.Errorf("failed to count collections summary: %w", err)
}
impl.Logger.Debug("counted collections summary successfully",
zap.String("user_id", userID.String()),
zap.String("filter_type", filterType),
zap.Int("owned_count", ownedCount),
zap.Int("shared_count", sharedCount))
return ownedCount, sharedCount, nil
}
// CountTotalUniqueFolders counts unique folders accessible to the user (deduplicates owned+shared)
func (impl *collectionRepositoryImpl) CountTotalUniqueFolders(ctx context.Context, userID gocql.UUID) (int, error) {
// Use a set to track unique collection IDs to avoid double-counting
uniqueCollectionIDs := make(map[gocql.UUID]bool)
// Query all collections for the user using the general table
query := `SELECT collection_id FROM maplefile.collections_by_user_id_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ?`
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
var collectionID gocql.UUID
totalScanned := 0
for iter.Scan(&collectionID) {
totalScanned++
// Get the collection to check state and type
collection, err := impl.getBaseCollection(ctx, collectionID)
if err != nil {
impl.Logger.Warn("failed to get collection for unique counting",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
continue
}
if collection == nil {
continue
}
impl.Logger.Debug("Processing collection for unique count",
zap.String("collection_id", collectionID.String()),
zap.String("state", collection.State),
zap.String("collection_type", collection.CollectionType),
zap.Int("total_scanned", totalScanned))
// Only count active folders
if collection.State != dom_collection.CollectionStateActive {
impl.Logger.Debug("Skipping collection due to non-active state",
zap.String("collection_id", collectionID.String()),
zap.String("state", collection.State))
continue
}
// Filter by folder type
if collection.CollectionType != dom_collection.CollectionTypeFolder {
impl.Logger.Debug("Skipping collection due to type filter",
zap.String("collection_id", collectionID.String()),
zap.String("collection_type", collection.CollectionType))
continue
}
// Add to unique set (automatically deduplicates)
uniqueCollectionIDs[collectionID] = true
impl.Logger.Debug("Added unique folder to count",
zap.String("collection_id", collectionID.String()),
zap.Int("current_unique_count", len(uniqueCollectionIDs)))
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to count unique folders",
zap.String("user_id", userID.String()),
zap.Error(err))
return 0, fmt.Errorf("failed to count unique folders: %w", err)
}
uniqueCount := len(uniqueCollectionIDs)
impl.Logger.Info("Unique folder count completed",
zap.String("user_id", userID.String()),
zap.Int("total_scanned", totalScanned),
zap.Int("unique_folders", uniqueCount))
return uniqueCount, nil
}

View file

@ -0,0 +1,214 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/create.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
func (impl *collectionRepositoryImpl) Create(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")
}
if !impl.isValidUUID(collection.OwnerID) {
return fmt.Errorf("owner ID is required")
}
// Set creation timestamp if not set
if collection.CreatedAt.IsZero() {
collection.CreatedAt = time.Now()
}
if collection.ModifiedAt.IsZero() {
collection.ModifiedAt = collection.CreatedAt
}
// Ensure state is set
if collection.State == "" {
collection.State = dom_collection.CollectionStateActive
}
// 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. Insert into main table
batch.Query(`INSERT INTO collections_by_id
(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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
collection.ID, collection.OwnerID, collection.EncryptedName, collection.CollectionType,
encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON, int64(0), // file_count starts at 0
tagsJSON, collection.CreatedAt, collection.CreatedByUserID, collection.ModifiedAt,
collection.ModifiedByUserID, collection.Version, collection.State,
collection.TombstoneVersion, collection.TombstoneExpiry)
// 2. Insert owner access into BOTH user access tables
// 2 -> (1 of 2): Original table: supports queries across all access types
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)
// 2 -> (2 of 2): Access-type-specific table for efficient filtering
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. Insert into original parent index (still needed for cross-owner parent-child queries)
parentID := collection.ParentID
if !impl.isValidUUID(parentID) {
parentID = impl.nullParentUUID() // Use null UUID for root collections
}
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 (?, ?, ?, ?, ?)`,
parentID, collection.CreatedAt, collection.ID, collection.OwnerID, collection.State)
// 4. Insert into composite partition key table for optimized root collection queries
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 (?, ?, ?, ?, ?)`,
parentID, collection.OwnerID, collection.CreatedAt, collection.ID, collection.State)
// 5. Insert into ancestor hierarchy table
ancestorEntries := impl.buildAncestorDepthEntries(collection.ID, collection.AncestorIDs)
for _, entry := range ancestorEntries {
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)
}
// 6. Insert into denormalized collections_by_tag_id table for each tag
for _, tag := range collection.Tags {
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
}
// 7. Insert members into normalized table AND both user access tables - WITH CONSISTENT VALIDATION
for i, member := range collection.Members {
impl.Logger.Info("processing member for creation",
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 - CONSISTENT WITH UPDATE METHOD
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 during creation",
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))
}
// Ensure member has an ID - CRITICAL: Set this before insertion
if !impl.isValidUUID(member.ID) {
member.ID = gocql.TimeUUID()
collection.Members[i].ID = member.ID // Update the collection's member slice
impl.Logger.Debug("generated member ID during creation",
zap.String("member_id", member.ID.String()),
zap.String("recipient_id", member.RecipientID.String()))
}
// Insert into normalized members table
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)
// Add member access to BOTH user access tables
// Original table: supports all-access-types queries
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)
// NEW: Access-type-specific table for efficient member queries
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)
}
// Execute batch - this ensures all tables are updated atomically
if err := impl.Session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
impl.Logger.Error("failed to create collection",
zap.String("collection_id", collection.ID.String()),
zap.Error(err))
return fmt.Errorf("failed to create collection: %w", err)
}
impl.Logger.Info("collection created successfully in all tables",
zap.String("collection_id", collection.ID.String()),
zap.String("owner_id", collection.OwnerID.String()),
zap.Int("member_count", len(collection.Members)))
return nil
}

View file

@ -0,0 +1,128 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/delete.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
func (impl *collectionRepositoryImpl) SoftDelete(ctx context.Context, id gocql.UUID) error {
collection, err := impl.Get(ctx, id)
if err != nil {
return fmt.Errorf("failed to get collection for soft delete: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
// Validate state transition
if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateDeleted); err != nil {
return fmt.Errorf("invalid state transition: %w", err)
}
// Update collection state
collection.State = dom_collection.CollectionStateDeleted
collection.ModifiedAt = time.Now()
collection.Version++
collection.TombstoneVersion = collection.Version
collection.TombstoneExpiry = time.Now().Add(30 * 24 * time.Hour) // 30 days
// Use the update method to ensure consistency across all tables
return impl.Update(ctx, collection)
}
func (impl *collectionRepositoryImpl) HardDelete(ctx context.Context, id gocql.UUID) error {
collection, err := impl.Get(ctx, id)
if err != nil {
return fmt.Errorf("failed to get collection for hard delete: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
batch := impl.Session.NewBatch(gocql.LoggedBatch)
// 1. Delete from main table
batch.Query(`DELETE FROM collections_by_id WHERE id = ?`, id)
// 2. Delete from BOTH user access tables (owner entries)
// This demonstrates the importance of cleaning up all table views during hard deletes
// Delete owner 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 = ?`,
collection.OwnerID, collection.ModifiedAt, id)
// Delete owner 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 = 'owner' AND modified_at = ? AND collection_id = ?`,
collection.OwnerID, collection.ModifiedAt, id)
// 3. Delete member access entries from BOTH user access tables
for _, member := range collection.Members {
// 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 = ?`,
member.RecipientID, collection.ModifiedAt, 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 = ?`,
member.RecipientID, collection.ModifiedAt, id)
}
// 4. Delete from original parent index
parentID := collection.ParentID
if !impl.isValidUUID(parentID) {
parentID = impl.nullParentUUID()
}
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 = ?`,
parentID, collection.CreatedAt, id)
// 5. Delete from composite partition key 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 = ?`,
parentID, collection.OwnerID, collection.CreatedAt, id)
// 6. Delete from ancestor hierarchy
ancestorEntries := impl.buildAncestorDepthEntries(id, collection.AncestorIDs)
for _, entry := range ancestorEntries {
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)
}
// 7. Delete from members table
batch.Query(`DELETE FROM collection_members_by_collection_id_and_recipient_id WHERE collection_id = ?`, id)
// 8. Delete from denormalized collections_by_tag_id table for all tags
for _, tag := range collection.Tags {
batch.Query(`DELETE FROM collections_by_tag_id
WHERE tag_id = ? AND collection_id = ?`,
tag.ID, id)
}
// Execute batch - ensures atomic deletion across all tables
if err := impl.Session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
impl.Logger.Error("failed to hard delete collection from all tables",
zap.String("collection_id", id.String()),
zap.Error(err))
return fmt.Errorf("failed to hard delete collection: %w", err)
}
impl.Logger.Info("collection hard deleted successfully from all tables",
zap.String("collection_id", id.String()),
zap.String("owner_id", collection.OwnerID.String()),
zap.Int("member_count", len(collection.Members)))
return nil
}

View file

@ -0,0 +1,82 @@
package collection
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// IncrementFileCount increments the file count for a collection
func (impl *collectionRepositoryImpl) IncrementFileCount(ctx context.Context, collectionID gocql.UUID) error {
// Read current file count
var currentCount int64
readQuery := `SELECT file_count FROM maplefile.collections_by_id WHERE id = ?`
if err := impl.Session.Query(readQuery, collectionID).WithContext(ctx).Scan(&currentCount); err != nil {
if err == gocql.ErrNotFound {
impl.Logger.Warn("collection not found for file count increment",
zap.String("collection_id", collectionID.String()))
return nil // Collection doesn't exist, nothing to increment
}
impl.Logger.Error("failed to read file count for increment",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return fmt.Errorf("failed to read file count: %w", err)
}
// Write incremented count
newCount := currentCount + 1
updateQuery := `UPDATE maplefile.collections_by_id SET file_count = ? WHERE id = ?`
if err := impl.Session.Query(updateQuery, newCount, collectionID).WithContext(ctx).Exec(); err != nil {
impl.Logger.Error("failed to increment file count",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return fmt.Errorf("failed to increment file count: %w", err)
}
impl.Logger.Debug("incremented file count",
zap.String("collection_id", collectionID.String()),
zap.Int64("old_count", currentCount),
zap.Int64("new_count", newCount))
return nil
}
// DecrementFileCount decrements the file count for a collection
func (impl *collectionRepositoryImpl) DecrementFileCount(ctx context.Context, collectionID gocql.UUID) error {
// Read current file count
var currentCount int64
readQuery := `SELECT file_count FROM maplefile.collections_by_id WHERE id = ?`
if err := impl.Session.Query(readQuery, collectionID).WithContext(ctx).Scan(&currentCount); err != nil {
if err == gocql.ErrNotFound {
impl.Logger.Warn("collection not found for file count decrement",
zap.String("collection_id", collectionID.String()))
return nil // Collection doesn't exist, nothing to decrement
}
impl.Logger.Error("failed to read file count for decrement",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return fmt.Errorf("failed to read file count: %w", err)
}
// Write decremented count (don't go below 0)
newCount := currentCount - 1
if newCount < 0 {
newCount = 0
}
updateQuery := `UPDATE maplefile.collections_by_id SET file_count = ? WHERE id = ?`
if err := impl.Session.Query(updateQuery, newCount, collectionID).WithContext(ctx).Exec(); err != nil {
impl.Logger.Error("failed to decrement file count",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return fmt.Errorf("failed to decrement file count: %w", err)
}
impl.Logger.Debug("decremented file count",
zap.String("collection_id", collectionID.String()),
zap.Int64("old_count", currentCount),
zap.Int64("new_count", newCount))
return nil
}

View file

@ -0,0 +1,482 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/get.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"
)
// Core helper methods for loading collections with members
func (impl *collectionRepositoryImpl) loadCollectionWithMembers(ctx context.Context, collectionID gocql.UUID) (*dom_collection.Collection, error) {
// 1. Load base collection
collection, err := impl.getBaseCollection(ctx, collectionID)
if err != nil || collection == nil {
return collection, err
}
// 2. Load and populate members
members, err := impl.getCollectionMembers(ctx, collectionID)
if err != nil {
return nil, err
}
collection.Members = members
return collection, nil
}
func (impl *collectionRepositoryImpl) getBaseCollection(ctx context.Context, id gocql.UUID) (*dom_collection.Collection, error) {
var (
encryptedName, collectionType, encryptedKeyJSON string
encryptedCustomIcon string
ancestorIDsJSON string
tagsJSON string
parentID, ownerID, createdByUserID, modifiedByUserID gocql.UUID
createdAt, modifiedAt, tombstoneExpiry time.Time
version, tombstoneVersion uint64
state string
fileCount int64
)
query := `SELECT 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
FROM collections_by_id WHERE id = ?`
err := impl.Session.Query(query, id).WithContext(ctx).Scan(
&id, &ownerID, &encryptedName, &collectionType, &encryptedKeyJSON,
&encryptedCustomIcon, &parentID, &ancestorIDsJSON, &fileCount, &tagsJSON, &createdAt, &createdByUserID,
&modifiedAt, &modifiedByUserID, &version, &state, &tombstoneVersion, &tombstoneExpiry)
if err != nil {
if err == gocql.ErrNotFound {
return nil, nil
}
return nil, fmt.Errorf("failed to get collection: %w", err)
}
// Deserialize complex fields
ancestorIDs, err := impl.deserializeAncestorIDs(ancestorIDsJSON)
if err != nil {
return nil, fmt.Errorf("failed to deserialize ancestor IDs: %w", err)
}
encryptedKey, err := impl.deserializeEncryptedCollectionKey(encryptedKeyJSON)
if err != nil {
return nil, fmt.Errorf("failed to deserialize encrypted collection key: %w", err)
}
tags, err := impl.deserializeTags(tagsJSON)
if err != nil {
return nil, fmt.Errorf("failed to deserialize tags: %w", err)
}
collection := &dom_collection.Collection{
ID: id,
OwnerID: ownerID,
EncryptedName: encryptedName,
CollectionType: collectionType,
EncryptedCollectionKey: encryptedKey,
EncryptedCustomIcon: encryptedCustomIcon,
Members: []dom_collection.CollectionMembership{}, // Will be populated separately
ParentID: parentID,
AncestorIDs: ancestorIDs,
FileCount: fileCount,
Tags: tags,
CreatedAt: createdAt,
CreatedByUserID: createdByUserID,
ModifiedAt: modifiedAt,
ModifiedByUserID: modifiedByUserID,
Version: version,
State: state,
TombstoneVersion: tombstoneVersion,
TombstoneExpiry: tombstoneExpiry,
}
return collection, nil
}
func (impl *collectionRepositoryImpl) getCollectionMembers(ctx context.Context, collectionID gocql.UUID) ([]dom_collection.CollectionMembership, error) {
var members []dom_collection.CollectionMembership
query := `SELECT recipient_id, member_id, recipient_email, granted_by_id,
encrypted_collection_key, permission_level, created_at,
is_inherited, inherited_from_id
FROM collection_members_by_collection_id_and_recipient_id WHERE collection_id = ?`
impl.Logger.Info("🔍 GET MEMBERS: Querying collection members",
zap.String("collection_id", collectionID.String()),
zap.String("query", query))
iter := impl.Session.Query(query, collectionID).WithContext(ctx).Iter()
var (
recipientID, memberID, grantedByID, inheritedFromID gocql.UUID
recipientEmail, permissionLevel string
encryptedCollectionKey []byte
createdAt time.Time
isInherited bool
)
for iter.Scan(&recipientID, &memberID, &recipientEmail, &grantedByID,
&encryptedCollectionKey, &permissionLevel, &createdAt,
&isInherited, &inheritedFromID) {
impl.Logger.Info("🔍 GET MEMBERS: Found member",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_email", validation.MaskEmail(recipientEmail)),
zap.String("recipient_id", recipientID.String()),
zap.Int("encrypted_key_length", len(encryptedCollectionKey)),
zap.String("permission_level", permissionLevel))
member := dom_collection.CollectionMembership{
ID: memberID,
CollectionID: collectionID,
RecipientID: recipientID,
RecipientEmail: recipientEmail,
GrantedByID: grantedByID,
EncryptedCollectionKey: encryptedCollectionKey,
PermissionLevel: permissionLevel,
CreatedAt: createdAt,
IsInherited: isInherited,
InheritedFromID: inheritedFromID,
}
members = append(members, member)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("🔍 GET MEMBERS: Failed to iterate members",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return nil, err
}
impl.Logger.Info("🔍 GET MEMBERS: Query completed",
zap.String("collection_id", collectionID.String()),
zap.Int("members_found", len(members)))
return members, nil
}
func (impl *collectionRepositoryImpl) loadMultipleCollectionsWithMembers(ctx context.Context, collectionIDs []gocql.UUID) ([]*dom_collection.Collection, error) {
if len(collectionIDs) == 0 {
return []*dom_collection.Collection{}, nil
}
var collections []*dom_collection.Collection
for _, id := range collectionIDs {
collection, err := impl.loadCollectionWithMembers(ctx, id)
if err != nil {
impl.Logger.Warn("failed to load collection",
zap.String("collection_id", id.String()),
zap.Error(err))
continue
}
if collection != nil {
collections = append(collections, collection)
}
}
return collections, nil
}
func (impl *collectionRepositoryImpl) Get(ctx context.Context, id gocql.UUID) (*dom_collection.Collection, error) {
return impl.loadCollectionWithMembers(ctx, id)
}
// FIXED: Removed state filtering from query, filter in memory instead
func (impl *collectionRepositoryImpl) GetAllByUserID(ctx context.Context, ownerID gocql.UUID) ([]*dom_collection.Collection, error) {
var collectionIDs []gocql.UUID
query := `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'owner'`
iter := impl.Session.Query(query, ownerID).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to get collections",
zap.Any("user_id", ownerID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get collections by owner: %w", err)
}
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
impl.Logger.Debug("retrieved owned collections efficiently",
zap.String("owner_id", ownerID.String()),
zap.Int("total_found", len(allCollections)),
zap.Int("active_count", len(activeCollections)))
return activeCollections, nil
}
// FIXED: Removed state filtering from query, filter in memory instead
func (impl *collectionRepositoryImpl) GetCollectionsSharedWithUser(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
impl.Logger.Info("🔍 REPO: Getting collections shared with user",
zap.String("user_id", userID.String()))
var collectionIDs []gocql.UUID
query := `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'member'`
impl.Logger.Info("🔍 REPO: Executing query",
zap.String("user_id", userID.String()),
zap.String("query", query))
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
impl.Logger.Info("🔍 REPO: Found collection ID in index",
zap.String("collection_id", collectionID.String()))
}
if err := iter.Close(); err != nil {
impl.Logger.Error("🔍 REPO: Query iteration failed",
zap.String("user_id", userID.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to get shared collections: %w", err)
}
impl.Logger.Info("🔍 REPO: Query completed",
zap.String("user_id", userID.String()),
zap.Int("collection_ids_found", len(collectionIDs)))
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
impl.Logger.Error("🔍 REPO: Failed to load collections",
zap.String("user_id", userID.String()),
zap.Error(err))
return nil, err
}
impl.Logger.Info("🔍 REPO: Loaded collections",
zap.String("user_id", userID.String()),
zap.Int("collections_loaded", len(allCollections)))
// Filter to only active collections AND collections where the user has actual membership
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
impl.Logger.Info("🔍 REPO: Checking collection state",
zap.String("collection_id", collection.ID.String()),
zap.String("state", collection.State),
zap.Bool("is_active", collection.State == dom_collection.CollectionStateActive))
if collection.State != dom_collection.CollectionStateActive {
impl.Logger.Info("🔍 REPO: Skipping inactive collection",
zap.String("collection_id", collection.ID.String()),
zap.String("state", collection.State))
continue
}
// Check if the user has actual membership in this collection
// For GetCollectionsSharedWithUser, we MUST have a membership record
// This is the source of truth, not the index
hasMembership := false
for _, member := range collection.Members {
if member.RecipientID == userID {
hasMembership = true
impl.Logger.Info("🔍 REPO: User has membership in collection",
zap.String("collection_id", collection.ID.String()),
zap.String("user_id", userID.String()),
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
zap.String("permission_level", member.PermissionLevel))
break
}
}
if !hasMembership {
// No actual membership record found - this is stale index data
// Skip this collection regardless of ownership
impl.Logger.Warn("🔍 REPO: Skipping collection with no actual membership (stale index)",
zap.String("collection_id", collection.ID.String()),
zap.String("user_id", userID.String()),
zap.Bool("is_owner", collection.OwnerID == userID),
zap.Int("members_count", len(collection.Members)))
continue
}
activeCollections = append(activeCollections, collection)
}
impl.Logger.Debug("retrieved shared collections efficiently",
zap.String("user_id", userID.String()),
zap.Int("total_found", len(allCollections)),
zap.Int("active_count", len(activeCollections)))
return activeCollections, nil
}
// NEW METHOD: Demonstrates querying across all access types when needed
func (impl *collectionRepositoryImpl) GetAllUserCollections(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
var collectionIDs []gocql.UUID
query := `SELECT collection_id FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ?`
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get all user collections: %w", err)
}
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
impl.Logger.Debug("retrieved all user collections efficiently",
zap.String("user_id", userID.String()),
zap.Int("total_found", len(allCollections)),
zap.Int("active_count", len(activeCollections)))
return activeCollections, nil
}
// Uses composite partition key table for better performance
func (impl *collectionRepositoryImpl) FindByParent(ctx context.Context, parentID gocql.UUID) ([]*dom_collection.Collection, error) {
var collectionIDs []gocql.UUID
query := `SELECT collection_id FROM collections_by_parent_id_with_asc_created_at_and_asc_collection_id
WHERE parent_id = ?`
iter := impl.Session.Query(query, parentID).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to find collections by parent: %w", err)
}
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
return activeCollections, nil
}
// Uses composite partition key for optimal performance
func (impl *collectionRepositoryImpl) FindRootCollections(ctx context.Context, ownerID gocql.UUID) ([]*dom_collection.Collection, error) {
var collectionIDs []gocql.UUID
// Use the composite partition key table for root collections
nullParentID := impl.nullParentUUID()
query := `SELECT collection_id FROM collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id
WHERE parent_id = ? AND owner_id = ?`
iter := impl.Session.Query(query, nullParentID, ownerID).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to find root collections: %w", err)
}
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
return activeCollections, nil
}
// No more recursive queries - single efficient query
func (impl *collectionRepositoryImpl) FindDescendants(ctx context.Context, collectionID gocql.UUID) ([]*dom_collection.Collection, error) {
var descendantIDs []gocql.UUID
query := `SELECT collection_id FROM collections_by_ancestor_id_with_asc_depth_and_asc_collection_id
WHERE ancestor_id = ?`
iter := impl.Session.Query(query, collectionID).WithContext(ctx).Iter()
var descendantID gocql.UUID
for iter.Scan(&descendantID) {
descendantIDs = append(descendantIDs, descendantID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to find descendants: %w", err)
}
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, descendantIDs)
if err != nil {
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
return activeCollections, nil
}

View file

@ -0,0 +1,237 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/get_filtered.go
package collection
import (
"context"
"fmt"
"github.com/gocql/gocql"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"go.uber.org/zap"
)
func (impl *collectionRepositoryImpl) GetCollectionsWithFilter(ctx context.Context, options dom_collection.CollectionFilterOptions) (*dom_collection.CollectionFilterResult, error) {
if !options.IsValid() {
return nil, fmt.Errorf("invalid filter options: at least one filter must be enabled")
}
result := &dom_collection.CollectionFilterResult{
OwnedCollections: []*dom_collection.Collection{},
SharedCollections: []*dom_collection.Collection{},
TotalCount: 0,
}
var err error
// Get owned collections if requested
if options.IncludeOwned {
result.OwnedCollections, err = impl.getOwnedCollectionsOptimized(ctx, options.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get owned collections: %w", err)
}
}
// Get shared collections if requested
if options.IncludeShared {
result.SharedCollections, err = impl.getSharedCollectionsOptimized(ctx, options.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get shared collections: %w", err)
}
}
result.TotalCount = len(result.OwnedCollections) + len(result.SharedCollections)
impl.Logger.Debug("completed filtered collection query",
zap.String("user_id", options.UserID.String()),
zap.Bool("include_owned", options.IncludeOwned),
zap.Bool("include_shared", options.IncludeShared),
zap.Int("owned_count", len(result.OwnedCollections)),
zap.Int("shared_count", len(result.SharedCollections)),
zap.Int("total_count", result.TotalCount))
return result, nil
}
// Uses the access-type-specific table for maximum efficiency
func (impl *collectionRepositoryImpl) getOwnedCollectionsOptimized(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
return impl.GetAllByUserID(ctx, userID)
}
// Uses the access-type-specific table
func (impl *collectionRepositoryImpl) getSharedCollectionsOptimized(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
return impl.GetCollectionsSharedWithUser(ctx, userID)
}
// Alternative approach when you need both types efficiently
func (impl *collectionRepositoryImpl) GetCollectionsWithFilterSingleQuery(ctx context.Context, options dom_collection.CollectionFilterOptions) (*dom_collection.CollectionFilterResult, error) {
if !options.IsValid() {
return nil, fmt.Errorf("invalid filter options: at least one filter must be enabled")
}
// Strategy decision: If we need both owned AND shared collections,
// it might be more efficient to query the original table once and separate them in memory
if options.ShouldIncludeAll() {
return impl.getAllCollectionsAndSeparate(ctx, options.UserID)
}
// If we only need one type, use the optimized single-type methods
return impl.GetCollectionsWithFilter(ctx, options)
}
// Helper method that demonstrates memory-based separation when it's more efficient
func (impl *collectionRepositoryImpl) getAllCollectionsAndSeparate(ctx context.Context, userID gocql.UUID) (*dom_collection.CollectionFilterResult, error) {
result := &dom_collection.CollectionFilterResult{
OwnedCollections: []*dom_collection.Collection{},
SharedCollections: []*dom_collection.Collection{},
TotalCount: 0,
}
// Query the original table to get all collections for the user
allCollections, err := impl.GetAllUserCollections(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get all user collections: %w", err)
}
// Separate owned from shared collections in memory
for _, collection := range allCollections {
if collection.OwnerID == userID {
result.OwnedCollections = append(result.OwnedCollections, collection)
} else {
// If the user is not the owner but has access, they must be a member
result.SharedCollections = append(result.SharedCollections, collection)
}
}
result.TotalCount = len(result.OwnedCollections) + len(result.SharedCollections)
impl.Logger.Debug("completed single-query filtered collection retrieval",
zap.String("user_id", userID.String()),
zap.Int("total_retrieved", len(allCollections)),
zap.Int("owned_count", len(result.OwnedCollections)),
zap.Int("shared_count", len(result.SharedCollections)))
return result, nil
}
// Advanced filtering with pagination support
func (impl *collectionRepositoryImpl) GetCollectionsWithFilterPaginated(ctx context.Context, options dom_collection.CollectionFilterOptions, limit int64, cursor *dom_collection.CollectionSyncCursor) (*dom_collection.CollectionFilterResult, error) {
if !options.IsValid() {
return nil, fmt.Errorf("invalid filter options: at least one filter must be enabled")
}
result := &dom_collection.CollectionFilterResult{
OwnedCollections: []*dom_collection.Collection{},
SharedCollections: []*dom_collection.Collection{},
TotalCount: 0,
}
if options.IncludeOwned {
ownedCollections, err := impl.getOwnedCollectionsPaginated(ctx, options.UserID, limit, cursor)
if err != nil {
return nil, fmt.Errorf("failed to get paginated owned collections: %w", err)
}
result.OwnedCollections = ownedCollections
}
if options.IncludeShared {
sharedCollections, err := impl.getSharedCollectionsPaginated(ctx, options.UserID, limit, cursor)
if err != nil {
return nil, fmt.Errorf("failed to get paginated shared collections: %w", err)
}
result.SharedCollections = sharedCollections
}
result.TotalCount = len(result.OwnedCollections) + len(result.SharedCollections)
return result, nil
}
// Helper method for paginated owned collections - removed state filtering from query
func (impl *collectionRepositoryImpl) getOwnedCollectionsPaginated(ctx context.Context, userID gocql.UUID, limit int64, cursor *dom_collection.CollectionSyncCursor) ([]*dom_collection.Collection, error) {
var collectionIDs []gocql.UUID
var query string
var args []any
// Build paginated query using the access-type-specific table - NO STATE FILTERING
if cursor == nil {
query = `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'owner' LIMIT ?`
args = []any{userID, limit}
} else {
query = `SELECT collection_id 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, collection_id) > (?, ?) LIMIT ?`
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
}
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get paginated owned collections: %w", err)
}
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
return activeCollections, nil
}
// Helper method for paginated shared collections - removed state filtering from query
func (impl *collectionRepositoryImpl) getSharedCollectionsPaginated(ctx context.Context, userID gocql.UUID, limit int64, cursor *dom_collection.CollectionSyncCursor) ([]*dom_collection.Collection, error) {
var collectionIDs []gocql.UUID
var query string
var args []any
// Build paginated query using the access-type-specific table - NO STATE FILTERING
if cursor == nil {
query = `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
WHERE user_id = ? AND access_type = 'member' LIMIT ?`
args = []any{userID, limit}
} else {
query = `SELECT collection_id 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, collection_id) > (?, ?) LIMIT ?`
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
}
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get paginated shared collections: %w", err)
}
// Load collections and filter by state in memory
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range allCollections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
return activeCollections, nil
}

View file

@ -0,0 +1,37 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/hierarchy.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
)
func (impl *collectionRepositoryImpl) MoveCollection(
ctx context.Context,
collectionID,
newParentID gocql.UUID,
updatedAncestors []gocql.UUID,
updatedPathSegments []string,
) error {
// Get the collection
collection, err := impl.Get(ctx, collectionID)
if err != nil {
return fmt.Errorf("failed to get collection: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
// Update hierarchy information
collection.ParentID = newParentID
collection.AncestorIDs = updatedAncestors
collection.ModifiedAt = time.Now()
collection.Version++
// Single update call handles all the complexity with the optimized schema
return impl.Update(ctx, collection)
}

View file

@ -0,0 +1,130 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/impl.go
package collection
import (
"encoding/json"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"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"
)
type collectionRepositoryImpl struct {
Logger *zap.Logger
Session *gocql.Session
}
func NewRepository(appCfg *config.Configuration, session *gocql.Session, loggerp *zap.Logger) dom_collection.CollectionRepository {
loggerp = loggerp.Named("CollectionRepository")
return &collectionRepositoryImpl{
Logger: loggerp,
Session: session,
}
}
// Helper functions for JSON serialization
func (impl *collectionRepositoryImpl) serializeAncestorIDs(ancestorIDs []gocql.UUID) (string, error) {
if len(ancestorIDs) == 0 {
return "[]", nil
}
data, err := json.Marshal(ancestorIDs)
return string(data), err
}
func (impl *collectionRepositoryImpl) deserializeAncestorIDs(data string) ([]gocql.UUID, error) {
if data == "" || data == "[]" {
return []gocql.UUID{}, nil
}
var ancestorIDs []gocql.UUID
err := json.Unmarshal([]byte(data), &ancestorIDs)
return ancestorIDs, err
}
func (impl *collectionRepositoryImpl) serializeEncryptedCollectionKey(key *crypto.EncryptedCollectionKey) (string, error) {
if key == nil {
return "", nil
}
data, err := json.Marshal(key)
return string(data), err
}
func (impl *collectionRepositoryImpl) deserializeEncryptedCollectionKey(data string) (*crypto.EncryptedCollectionKey, error) {
if data == "" {
return nil, nil
}
var key crypto.EncryptedCollectionKey
err := json.Unmarshal([]byte(data), &key)
return &key, err
}
func (impl *collectionRepositoryImpl) serializeTags(tags []tag.EmbeddedTag) (string, error) {
if len(tags) == 0 {
return "[]", nil
}
data, err := json.Marshal(tags)
return string(data), err
}
func (impl *collectionRepositoryImpl) deserializeTags(data string) ([]tag.EmbeddedTag, error) {
if data == "" || data == "[]" {
return []tag.EmbeddedTag{}, nil
}
var tags []tag.EmbeddedTag
err := json.Unmarshal([]byte(data), &tags)
return tags, err
}
// isValidUUID checks if UUID is not nil/empty
func (impl *collectionRepositoryImpl) isValidUUID(id gocql.UUID) bool {
return id.String() != "00000000-0000-0000-0000-000000000000"
}
// Permission helper method
func (impl *collectionRepositoryImpl) hasPermission(userPermission, requiredPermission string) bool {
permissionLevels := map[string]int{
dom_collection.CollectionPermissionReadOnly: 1,
dom_collection.CollectionPermissionReadWrite: 2,
dom_collection.CollectionPermissionAdmin: 3,
}
userLevel, userExists := permissionLevels[userPermission]
requiredLevel, requiredExists := permissionLevels[requiredPermission]
if !userExists || !requiredExists {
return false
}
return userLevel >= requiredLevel
}
// Helper to generate null UUID for root collections
func (impl *collectionRepositoryImpl) nullParentUUID() gocql.UUID {
return gocql.UUID{} // All zeros represents null parent
}
// Helper to build ancestor depth entries for hierarchy table
func (impl *collectionRepositoryImpl) buildAncestorDepthEntries(collectionID gocql.UUID, ancestorIDs []gocql.UUID) []ancestorDepthEntry {
var entries []ancestorDepthEntry
for i, ancestorID := range ancestorIDs {
depth := i + 1 // Depth starts at 1 for direct parent
entries = append(entries, ancestorDepthEntry{
AncestorID: ancestorID,
Depth: depth,
CollectionID: collectionID,
})
}
return entries
}
type ancestorDepthEntry struct {
AncestorID gocql.UUID
Depth int
CollectionID gocql.UUID
}

View file

@ -0,0 +1,65 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/list_by_tag_id.go
package collection
import (
"context"
"fmt"
"github.com/gocql/gocql"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"go.uber.org/zap"
)
// ListByTagID retrieves all collections that have the specified tag assigned
// Uses the denormalized collections_by_tag_id table for efficient lookups
func (impl *collectionRepositoryImpl) ListByTagID(ctx context.Context, tagID gocql.UUID) ([]*dom_collection.Collection, error) {
impl.Logger.Info("🏷️ REPO: Listing collections by tag ID",
zap.String("tag_id", tagID.String()))
var collectionIDs []gocql.UUID
// Query the denormalized table
query := `SELECT collection_id FROM collections_by_tag_id WHERE tag_id = ?`
iter := impl.Session.Query(query, tagID).WithContext(ctx).Iter()
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("🏷️ REPO: Failed to query collections by tag",
zap.String("tag_id", tagID.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to list collections by tag: %w", err)
}
impl.Logger.Info("🏷️ REPO: Found collection IDs for tag",
zap.String("tag_id", tagID.String()),
zap.Int("count", len(collectionIDs)))
// Load full collection details with members
collections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
if err != nil {
impl.Logger.Error("🏷️ REPO: Failed to load collections",
zap.String("tag_id", tagID.String()),
zap.Error(err))
return nil, err
}
// Filter to only active collections
var activeCollections []*dom_collection.Collection
for _, collection := range collections {
if collection.State == dom_collection.CollectionStateActive {
activeCollections = append(activeCollections, collection)
}
}
impl.Logger.Info("🏷️ REPO: Successfully loaded collections by tag",
zap.String("tag_id", tagID.String()),
zap.Int("total_found", len(collections)),
zap.Int("active_count", len(activeCollections)))
return activeCollections, nil
}

View file

@ -0,0 +1,14 @@
package collection
import (
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
// ProvideRepository provides a collection repository for Wire DI
func ProvideRepository(cfg *config.Config, session *gocql.Session, logger *zap.Logger) dom_collection.CollectionRepository {
return NewRepository(cfg, session, logger)
}

View file

@ -0,0 +1,75 @@
package collection
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
// RecalculateAllFileCounts recalculates the file_count for all collections
// by counting active files in each collection. This is useful for fixing
// collections that were created before file count tracking was implemented.
func (impl *collectionRepositoryImpl) RecalculateAllFileCounts(ctx context.Context) (*dom_collection.RecalculateAllFileCountsResult, error) {
impl.Logger.Info("Starting recalculation of all collection file counts")
result := &dom_collection.RecalculateAllFileCountsResult{}
// Get all collection IDs
query := `SELECT id FROM maplefile.collections_by_id`
iter := impl.Session.Query(query).WithContext(ctx).Iter()
var collectionIDs []gocql.UUID
var collectionID gocql.UUID
for iter.Scan(&collectionID) {
collectionIDs = append(collectionIDs, collectionID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get collection IDs: %w", err)
}
result.TotalCollections = len(collectionIDs)
impl.Logger.Info("Found collections to process",
zap.Int("count", result.TotalCollections))
// For each collection, count active files and update
for _, colID := range collectionIDs {
// Count active files in this collection
countQuery := `SELECT COUNT(*) FROM maplefile.files_by_collection WHERE collection_id = ? AND state = 'active' ALLOW FILTERING`
var fileCount int64
if err := impl.Session.Query(countQuery, colID).WithContext(ctx).Scan(&fileCount); err != nil {
impl.Logger.Error("Failed to count files for collection",
zap.String("collection_id", colID.String()),
zap.Error(err))
result.ErrorCount++
continue
}
// Update the collection's file_count
updateQuery := `UPDATE maplefile.collections_by_id SET file_count = ? WHERE id = ?`
if err := impl.Session.Query(updateQuery, fileCount, colID).WithContext(ctx).Exec(); err != nil {
impl.Logger.Error("Failed to update file count for collection",
zap.String("collection_id", colID.String()),
zap.Int64("file_count", fileCount),
zap.Error(err))
result.ErrorCount++
continue
}
result.UpdatedCount++
impl.Logger.Debug("Updated file count for collection",
zap.String("collection_id", colID.String()),
zap.Int64("file_count", fileCount))
}
impl.Logger.Info("Completed recalculation of all collection file counts",
zap.Int("total", result.TotalCollections),
zap.Int("updated", result.UpdatedCount),
zap.Int("errors", result.ErrorCount))
return result, nil
}

View file

@ -0,0 +1,36 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/restore.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
func (impl *collectionRepositoryImpl) Restore(ctx context.Context, id gocql.UUID) error {
collection, err := impl.Get(ctx, id)
if err != nil {
return fmt.Errorf("failed to get collection for restore: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
// Validate state transition
if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateActive); err != nil {
return fmt.Errorf("invalid state transition: %w", err)
}
// Update collection state
collection.State = dom_collection.CollectionStateActive
collection.ModifiedAt = time.Now()
collection.Version++
collection.TombstoneVersion = 0
collection.TombstoneExpiry = time.Time{}
return impl.Update(ctx, collection)
}

View file

@ -0,0 +1,496 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/share.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) AddMember(ctx context.Context, collectionID gocql.UUID, membership *dom_collection.CollectionMembership) error {
if membership == nil {
return fmt.Errorf("membership cannot be nil")
}
impl.Logger.Info("starting add member process",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.String("recipient_email", validation.MaskEmail(membership.RecipientEmail)),
zap.String("permission_level", membership.PermissionLevel))
// Validate membership data with enhanced checks
if !impl.isValidUUID(membership.RecipientID) {
return fmt.Errorf("invalid recipient ID")
}
if membership.RecipientEmail == "" {
return fmt.Errorf("recipient email is required")
}
if membership.PermissionLevel == "" {
membership.PermissionLevel = dom_collection.CollectionPermissionReadOnly
}
// CRITICAL: Validate encrypted collection key for shared members
if len(membership.EncryptedCollectionKey) == 0 {
impl.Logger.Error("CRITICAL: Attempt to add member without encrypted collection key",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.String("recipient_email", validation.MaskEmail(membership.RecipientEmail)),
zap.Int("encrypted_key_length", len(membership.EncryptedCollectionKey)))
return fmt.Errorf("encrypted collection key is required for shared members")
}
// Additional validation: ensure the encrypted key is reasonable size
if len(membership.EncryptedCollectionKey) < 32 {
impl.Logger.Error("encrypted collection key appears too short",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Int("encrypted_key_length", len(membership.EncryptedCollectionKey)))
return fmt.Errorf("encrypted collection key appears invalid (got %d bytes, expected at least 32)", len(membership.EncryptedCollectionKey))
}
impl.Logger.Info("validated encrypted collection key for new member",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Int("encrypted_key_length", len(membership.EncryptedCollectionKey)))
// Load collection
collection, err := impl.Get(ctx, collectionID)
if err != nil {
impl.Logger.Error("failed to get collection for member addition",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return fmt.Errorf("failed to get collection: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
impl.Logger.Info("loaded collection for member addition",
zap.String("collection_id", collection.ID.String()),
zap.String("collection_state", collection.State),
zap.Int("existing_members", len(collection.Members)))
// Ensure member has an ID BEFORE adding to collection
if !impl.isValidUUID(membership.ID) {
membership.ID = gocql.TimeUUID()
impl.Logger.Debug("generated new member ID", zap.String("member_id", membership.ID.String()))
}
// Set creation time if not set
if membership.CreatedAt.IsZero() {
membership.CreatedAt = time.Now()
}
// Set collection ID (ensure it matches)
membership.CollectionID = collectionID
// Check if member already exists and update or add
memberExists := false
for i, existingMember := range collection.Members {
if existingMember.RecipientID == membership.RecipientID {
impl.Logger.Info("updating existing collection member",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.String("old_permission", existingMember.PermissionLevel),
zap.String("new_permission", membership.PermissionLevel))
// IMPORTANT: Preserve the existing member ID to avoid creating a new one
membership.ID = existingMember.ID
collection.Members[i] = *membership
memberExists = true
break
}
}
if !memberExists {
impl.Logger.Info("adding new collection member",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.String("permission_level", membership.PermissionLevel))
collection.Members = append(collection.Members, *membership)
impl.Logger.Info("DEBUGGING: Member added to collection.Members slice",
zap.String("collection_id", collectionID.String()),
zap.String("new_member_id", membership.ID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Int("total_members_now", len(collection.Members)))
}
// Update version
collection.Version++
collection.ModifiedAt = time.Now()
impl.Logger.Info("prepared collection for update with member",
zap.String("collection_id", collection.ID.String()),
zap.Int("total_members", len(collection.Members)),
zap.Uint64("version", collection.Version))
// DEBUGGING: Log all members that will be sent to Update method
impl.Logger.Info("DEBUGGING: About to call Update() with these members:")
for debugIdx, debugMember := range collection.Members {
isOwner := debugMember.RecipientID == collection.OwnerID
impl.Logger.Info("DEBUGGING: Member in collection.Members slice",
zap.Int("index", debugIdx),
zap.String("member_id", debugMember.ID.String()),
zap.String("recipient_id", debugMember.RecipientID.String()),
zap.String("recipient_email", validation.MaskEmail(debugMember.RecipientEmail)),
zap.String("permission_level", debugMember.PermissionLevel),
zap.Bool("is_owner", isOwner),
zap.Int("encrypted_key_length", len(debugMember.EncryptedCollectionKey)))
}
// Log all members for debugging
for i, member := range collection.Members {
isOwner := member.RecipientID == collection.OwnerID
impl.Logger.Debug("collection member details",
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),
zap.Bool("is_inherited", member.IsInherited),
zap.Bool("is_owner", isOwner),
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
}
// Call update - the Update method itself is atomic and reliable
err = impl.Update(ctx, collection)
if err != nil {
impl.Logger.Error("failed to update collection with new member",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Error(err))
return fmt.Errorf("failed to update collection: %w", err)
}
impl.Logger.Info("successfully added member to collection",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.String("member_id", membership.ID.String()))
// DEVELOPER NOTE:
// Remove the immediate verification after update since Cassandra needs time to propagate:
// // DEBUGGING: Test if we can query the members table directly
// impl.Logger.Info("DEBUGGING: Testing direct access to members table")
// err = impl.testMembersTableAccess(ctx, collectionID)
// if err != nil {
// impl.Logger.Error("DEBUGGING: Failed to access members table",
// zap.String("collection_id", collectionID.String()),
// zap.Error(err))
// } else {
// impl.Logger.Info("DEBUGGING: Members table access test successful",
// zap.String("collection_id", collectionID.String()))
// }
return nil
}
// testDirectMemberInsert tests inserting directly into the members table (for debugging)
func (impl *collectionRepositoryImpl) testDirectMemberInsert(ctx context.Context, collectionID gocql.UUID, membership *dom_collection.CollectionMembership) error {
impl.Logger.Info("DEBUGGING: Testing direct insert into members table",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()))
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
err := impl.Session.Query(query,
collectionID, membership.RecipientID, membership.ID, membership.RecipientEmail,
membership.GrantedByID, membership.EncryptedCollectionKey,
membership.PermissionLevel, membership.CreatedAt,
membership.IsInherited, membership.InheritedFromID).WithContext(ctx).Exec()
if err != nil {
impl.Logger.Error("DEBUGGING: Direct insert failed",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Error(err))
return fmt.Errorf("direct insert failed: %w", err)
}
impl.Logger.Info("DEBUGGING: Direct insert successful",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()))
// Verify the insert worked
var foundMemberID gocql.UUID
verifyQuery := `SELECT member_id FROM collection_members_by_collection_id_and_recipient_id
WHERE collection_id = ? AND recipient_id = ?`
err = impl.Session.Query(verifyQuery, collectionID, membership.RecipientID).WithContext(ctx).Scan(&foundMemberID)
if err != nil {
if err == gocql.ErrNotFound {
impl.Logger.Error("DEBUGGING: Direct insert verification failed - member not found",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()))
return fmt.Errorf("direct insert verification failed - member not found")
}
impl.Logger.Error("DEBUGGING: Direct insert verification error",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Error(err))
return fmt.Errorf("verification query failed: %w", err)
}
impl.Logger.Info("DEBUGGING: Direct insert verification successful",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.String("found_member_id", foundMemberID.String()))
return nil
}
// testMembersTableAccess verifies we can read from the members table
func (impl *collectionRepositoryImpl) testMembersTableAccess(ctx context.Context, collectionID gocql.UUID) error {
query := `SELECT COUNT(*) FROM collection_members_by_collection_id_and_recipient_id WHERE collection_id = ?`
var count int
err := impl.Session.Query(query, collectionID).WithContext(ctx).Scan(&count)
if err != nil {
return fmt.Errorf("failed to query members table: %w", err)
}
impl.Logger.Info("DEBUGGING: Members table query successful",
zap.String("collection_id", collectionID.String()),
zap.Int("member_count", count))
return nil
}
func (impl *collectionRepositoryImpl) RemoveMember(ctx context.Context, collectionID, recipientID gocql.UUID) error {
// Load collection, remove member, and save
collection, err := impl.Get(ctx, collectionID)
if err != nil {
return fmt.Errorf("failed to get collection: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
// Remove member from collection
var updatedMembers []dom_collection.CollectionMembership
found := false
for _, member := range collection.Members {
if member.RecipientID != recipientID {
updatedMembers = append(updatedMembers, member)
} else {
found = true
}
}
if !found {
return fmt.Errorf("member not found in collection")
}
collection.Members = updatedMembers
collection.Version++
return impl.Update(ctx, collection)
}
func (impl *collectionRepositoryImpl) UpdateMemberPermission(ctx context.Context, collectionID, recipientID gocql.UUID, newPermission string) error {
// Load collection, update member permission, and save
collection, err := impl.Get(ctx, collectionID)
if err != nil {
return fmt.Errorf("failed to get collection: %w", err)
}
if collection == nil {
return fmt.Errorf("collection not found")
}
// Update member permission
found := false
for i, member := range collection.Members {
if member.RecipientID == recipientID {
collection.Members[i].PermissionLevel = newPermission
found = true
break
}
}
if !found {
return fmt.Errorf("member not found in collection")
}
collection.Version++
return impl.Update(ctx, collection)
}
func (impl *collectionRepositoryImpl) GetCollectionMembership(ctx context.Context, collectionID, recipientID gocql.UUID) (*dom_collection.CollectionMembership, error) {
var membership dom_collection.CollectionMembership
query := `SELECT recipient_id, member_id, recipient_email, granted_by_id,
encrypted_collection_key, permission_level, created_at,
is_inherited, inherited_from_id
FROM collection_members_by_collection_id_and_recipient_id
WHERE collection_id = ? AND recipient_id = ?`
err := impl.Session.Query(query, collectionID, recipientID).WithContext(ctx).Scan(
&membership.RecipientID, &membership.ID, &membership.RecipientEmail, &membership.GrantedByID,
&membership.EncryptedCollectionKey, &membership.PermissionLevel,
&membership.CreatedAt, &membership.IsInherited, &membership.InheritedFromID)
if err != nil {
if err == gocql.ErrNotFound {
return nil, nil
}
return nil, err
}
membership.CollectionID = collectionID
return &membership, nil
}
func (impl *collectionRepositoryImpl) AddMemberToHierarchy(ctx context.Context, rootID gocql.UUID, membership *dom_collection.CollectionMembership) error {
// Get all descendants of the root collection
descendants, err := impl.FindDescendants(ctx, rootID)
if err != nil {
return fmt.Errorf("failed to find descendants: %w", err)
}
impl.Logger.Info("adding member to collection hierarchy",
zap.String("root_collection_id", rootID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Int("descendants_count", len(descendants)))
// Add to root collection
if err := impl.AddMember(ctx, rootID, membership); err != nil {
return fmt.Errorf("failed to add member to root collection: %w", err)
}
// Add to all descendants with inherited flag
inheritedMembership := *membership
inheritedMembership.IsInherited = true
inheritedMembership.InheritedFromID = rootID
successCount := 0
for _, descendant := range descendants {
// Generate new ID for each inherited membership
inheritedMembership.ID = gocql.TimeUUID()
if err := impl.AddMember(ctx, descendant.ID, &inheritedMembership); err != nil {
impl.Logger.Warn("failed to add inherited member to descendant",
zap.String("descendant_id", descendant.ID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Error(err))
} else {
successCount++
}
}
impl.Logger.Info("completed hierarchy member addition",
zap.String("root_collection_id", rootID.String()),
zap.String("recipient_id", membership.RecipientID.String()),
zap.Int("total_descendants", len(descendants)),
zap.Int("successful_additions", successCount))
return nil
}
func (impl *collectionRepositoryImpl) RemoveMemberFromHierarchy(ctx context.Context, rootID, recipientID gocql.UUID) error {
// Get all descendants of the root collection
descendants, err := impl.FindDescendants(ctx, rootID)
if err != nil {
return fmt.Errorf("failed to find descendants: %w", err)
}
// Remove from root collection
if err := impl.RemoveMember(ctx, rootID, recipientID); err != nil {
return fmt.Errorf("failed to remove member from root collection: %w", err)
}
// Remove from all descendants where access was inherited from this root
for _, descendant := range descendants {
// Only remove if the membership was inherited from this root
membership, err := impl.GetCollectionMembership(ctx, descendant.ID, recipientID)
if err != nil {
impl.Logger.Warn("failed to get membership for descendant",
zap.String("descendant_id", descendant.ID.String()),
zap.Error(err))
continue
}
if membership != nil && membership.IsInherited && membership.InheritedFromID == rootID {
if err := impl.RemoveMember(ctx, descendant.ID, recipientID); err != nil {
impl.Logger.Warn("failed to remove inherited member from descendant",
zap.String("descendant_id", descendant.ID.String()),
zap.String("recipient_id", recipientID.String()),
zap.Error(err))
}
}
}
return nil
}
// RemoveUserFromAllCollections removes a user from all collections they are a member of
// Used for GDPR right-to-be-forgotten implementation
// Returns a list of collection IDs that were modified
func (impl *collectionRepositoryImpl) RemoveUserFromAllCollections(ctx context.Context, userID gocql.UUID, userEmail string) ([]gocql.UUID, error) {
impl.Logger.Info("Removing user from all shared collections",
zap.String("user_id", userID.String()),
zap.String("user_email", validation.MaskEmail(userEmail)))
// Get all collections shared with the user
sharedCollections, err := impl.GetCollectionsSharedWithUser(ctx, userID)
if err != nil {
impl.Logger.Error("Failed to get collections shared with user",
zap.String("user_id", userID.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to get shared collections: %w", err)
}
impl.Logger.Info("Found shared collections for user",
zap.String("user_id", userID.String()),
zap.Int("collection_count", len(sharedCollections)))
var modifiedCollections []gocql.UUID
successCount := 0
failureCount := 0
// Remove user from each collection
for _, collection := range sharedCollections {
err := impl.RemoveMember(ctx, collection.ID, userID)
if err != nil {
impl.Logger.Warn("Failed to remove user from collection",
zap.String("collection_id", collection.ID.String()),
zap.String("user_id", userID.String()),
zap.Error(err))
failureCount++
// Continue with other collections despite error
continue
}
modifiedCollections = append(modifiedCollections, collection.ID)
successCount++
impl.Logger.Debug("Removed user from collection",
zap.String("collection_id", collection.ID.String()),
zap.String("user_id", userID.String()))
}
impl.Logger.Info("✅ Completed removing user from shared collections",
zap.String("user_id", userID.String()),
zap.Int("total_collections", len(sharedCollections)),
zap.Int("success_count", successCount),
zap.Int("failure_count", failureCount),
zap.Int("modified_collections", len(modifiedCollections)))
// Return success even if some removals failed - partial success is acceptable
return modifiedCollections, nil
}

View file

@ -0,0 +1,438 @@
// 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
}