Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
34
cloud/maplefile-backend/internal/repo/collection/archive.go
Normal file
34
cloud/maplefile-backend/internal/repo/collection/archive.go
Normal 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)
|
||||
}
|
||||
160
cloud/maplefile-backend/internal/repo/collection/check.go
Normal file
160
cloud/maplefile-backend/internal/repo/collection/check.go
Normal 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(¤tCollectionID, &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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
334
cloud/maplefile-backend/internal/repo/collection/count.go
Normal file
334
cloud/maplefile-backend/internal/repo/collection/count.go
Normal 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
|
||||
}
|
||||
214
cloud/maplefile-backend/internal/repo/collection/create.go
Normal file
214
cloud/maplefile-backend/internal/repo/collection/create.go
Normal 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
|
||||
}
|
||||
128
cloud/maplefile-backend/internal/repo/collection/delete.go
Normal file
128
cloud/maplefile-backend/internal/repo/collection/delete.go
Normal 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
|
||||
}
|
||||
|
|
@ -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(¤tCount); 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(¤tCount); 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
|
||||
}
|
||||
482
cloud/maplefile-backend/internal/repo/collection/get.go
Normal file
482
cloud/maplefile-backend/internal/repo/collection/get.go
Normal 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
|
||||
}
|
||||
237
cloud/maplefile-backend/internal/repo/collection/get_filtered.go
Normal file
237
cloud/maplefile-backend/internal/repo/collection/get_filtered.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
130
cloud/maplefile-backend/internal/repo/collection/impl.go
Normal file
130
cloud/maplefile-backend/internal/repo/collection/impl.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
14
cloud/maplefile-backend/internal/repo/collection/provider.go
Normal file
14
cloud/maplefile-backend/internal/repo/collection/provider.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
36
cloud/maplefile-backend/internal/repo/collection/restore.go
Normal file
36
cloud/maplefile-backend/internal/repo/collection/restore.go
Normal 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)
|
||||
}
|
||||
496
cloud/maplefile-backend/internal/repo/collection/share.go
Normal file
496
cloud/maplefile-backend/internal/repo/collection/share.go
Normal 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
|
||||
}
|
||||
438
cloud/maplefile-backend/internal/repo/collection/update.go
Normal file
438
cloud/maplefile-backend/internal/repo/collection/update.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue