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

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

View file

@ -0,0 +1,199 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/blockedemail/blockedemail.go
package blockedemail
import (
"context"
"strings"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
dom_blockedemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/blockedemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
type blockedEmailRepositoryImpl struct {
config *config.Configuration
logger *zap.Logger
session *gocql.Session
}
func NewBlockedEmailRepository(
config *config.Configuration,
logger *zap.Logger,
session *gocql.Session,
) dom_blockedemail.BlockedEmailRepository {
logger = logger.Named("BlockedEmailRepository")
return &blockedEmailRepositoryImpl{
config: config,
logger: logger,
session: session,
}
}
func (r *blockedEmailRepositoryImpl) Create(ctx context.Context, blockedEmail *dom_blockedemail.BlockedEmail) error {
// Normalize email to lowercase
normalizedEmail := strings.ToLower(strings.TrimSpace(blockedEmail.BlockedEmail))
query := `INSERT INTO user_blocked_emails (
user_id, blocked_email, blocked_user_id, reason, created_at
) VALUES (?, ?, ?, ?, ?)`
err := r.session.Query(query,
blockedEmail.UserID,
normalizedEmail,
blockedEmail.BlockedUserID,
blockedEmail.Reason,
blockedEmail.CreatedAt,
).WithContext(ctx).Exec()
if err != nil {
r.logger.Error("Failed to create blocked email",
zap.Any("error", err),
zap.Any("user_id", blockedEmail.UserID),
zap.String("blocked_email", validation.MaskEmail(normalizedEmail)))
return err
}
r.logger.Debug("Blocked email created",
zap.Any("user_id", blockedEmail.UserID),
zap.String("blocked_email", validation.MaskEmail(normalizedEmail)))
return nil
}
func (r *blockedEmailRepositoryImpl) Get(ctx context.Context, userID gocql.UUID, blockedEmail string) (*dom_blockedemail.BlockedEmail, error) {
// Normalize email to lowercase
normalizedEmail := strings.ToLower(strings.TrimSpace(blockedEmail))
query := `SELECT user_id, blocked_email, blocked_user_id, reason, created_at
FROM user_blocked_emails
WHERE user_id = ? AND blocked_email = ?`
var result dom_blockedemail.BlockedEmail
err := r.session.Query(query, userID, normalizedEmail).
WithContext(ctx).
Scan(
&result.UserID,
&result.BlockedEmail,
&result.BlockedUserID,
&result.Reason,
&result.CreatedAt,
)
if err != nil {
if err == gocql.ErrNotFound {
return nil, nil
}
r.logger.Error("Failed to get blocked email",
zap.Any("error", err),
zap.Any("user_id", userID),
zap.String("blocked_email", validation.MaskEmail(normalizedEmail)))
return nil, err
}
return &result, nil
}
func (r *blockedEmailRepositoryImpl) List(ctx context.Context, userID gocql.UUID) ([]*dom_blockedemail.BlockedEmail, error) {
query := `SELECT user_id, blocked_email, blocked_user_id, reason, created_at
FROM user_blocked_emails
WHERE user_id = ?`
iter := r.session.Query(query, userID).WithContext(ctx).Iter()
var results []*dom_blockedemail.BlockedEmail
var entry dom_blockedemail.BlockedEmail
for iter.Scan(
&entry.UserID,
&entry.BlockedEmail,
&entry.BlockedUserID,
&entry.Reason,
&entry.CreatedAt,
) {
results = append(results, &dom_blockedemail.BlockedEmail{
UserID: entry.UserID,
BlockedEmail: entry.BlockedEmail,
BlockedUserID: entry.BlockedUserID,
Reason: entry.Reason,
CreatedAt: entry.CreatedAt,
})
}
if err := iter.Close(); err != nil {
r.logger.Error("Failed to list blocked emails",
zap.Any("error", err),
zap.Any("user_id", userID))
return nil, err
}
r.logger.Debug("Listed blocked emails",
zap.Any("user_id", userID),
zap.Int("count", len(results)))
return results, nil
}
func (r *blockedEmailRepositoryImpl) Delete(ctx context.Context, userID gocql.UUID, blockedEmail string) error {
// Normalize email to lowercase
normalizedEmail := strings.ToLower(strings.TrimSpace(blockedEmail))
query := `DELETE FROM user_blocked_emails WHERE user_id = ? AND blocked_email = ?`
err := r.session.Query(query, userID, normalizedEmail).WithContext(ctx).Exec()
if err != nil {
r.logger.Error("Failed to delete blocked email",
zap.Any("error", err),
zap.Any("user_id", userID),
zap.String("blocked_email", validation.MaskEmail(normalizedEmail)))
return err
}
r.logger.Debug("Blocked email deleted",
zap.Any("user_id", userID),
zap.String("blocked_email", validation.MaskEmail(normalizedEmail)))
return nil
}
func (r *blockedEmailRepositoryImpl) IsBlocked(ctx context.Context, userID gocql.UUID, email string) (bool, error) {
// Normalize email to lowercase
normalizedEmail := strings.ToLower(strings.TrimSpace(email))
query := `SELECT blocked_email FROM user_blocked_emails WHERE user_id = ? AND blocked_email = ?`
var blockedEmail string
err := r.session.Query(query, userID, normalizedEmail).
WithContext(ctx).
Scan(&blockedEmail)
if err != nil {
if err == gocql.ErrNotFound {
return false, nil
}
r.logger.Error("Failed to check if email is blocked",
zap.Any("error", err),
zap.Any("user_id", userID),
zap.String("email", validation.MaskEmail(normalizedEmail)))
return false, err
}
return true, nil
}
func (r *blockedEmailRepositoryImpl) Count(ctx context.Context, userID gocql.UUID) (int, error) {
query := `SELECT COUNT(*) FROM user_blocked_emails WHERE user_id = ?`
var count int
err := r.session.Query(query, userID).WithContext(ctx).Scan(&count)
if err != nil {
r.logger.Error("Failed to count blocked emails",
zap.Any("error", err),
zap.Any("user_id", userID))
return 0, err
}
return count, nil
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,214 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/create.go
package collection
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
func (impl *collectionRepositoryImpl) Create(ctx context.Context, collection *dom_collection.Collection) error {
if collection == nil {
return fmt.Errorf("collection cannot be nil")
}
if !impl.isValidUUID(collection.ID) {
return fmt.Errorf("collection ID is required")
}
if !impl.isValidUUID(collection.OwnerID) {
return fmt.Errorf("owner ID is required")
}
// Set creation timestamp if not set
if collection.CreatedAt.IsZero() {
collection.CreatedAt = time.Now()
}
if collection.ModifiedAt.IsZero() {
collection.ModifiedAt = collection.CreatedAt
}
// Ensure state is set
if collection.State == "" {
collection.State = dom_collection.CollectionStateActive
}
// Serialize complex fields
ancestorIDsJSON, err := impl.serializeAncestorIDs(collection.AncestorIDs)
if err != nil {
return fmt.Errorf("failed to serialize ancestor IDs: %w", err)
}
encryptedKeyJSON, err := impl.serializeEncryptedCollectionKey(collection.EncryptedCollectionKey)
if err != nil {
return fmt.Errorf("failed to serialize encrypted collection key: %w", err)
}
tagsJSON, err := impl.serializeTags(collection.Tags)
if err != nil {
return fmt.Errorf("failed to serialize tags: %w", err)
}
batch := impl.Session.NewBatch(gocql.LoggedBatch)
// 1. Insert into main table
batch.Query(`INSERT INTO collections_by_id
(id, owner_id, encrypted_name, collection_type, encrypted_collection_key,
encrypted_custom_icon, parent_id, ancestor_ids, file_count, tags, created_at, created_by_user_id,
modified_at, modified_by_user_id, version, state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
collection.ID, collection.OwnerID, collection.EncryptedName, collection.CollectionType,
encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON, int64(0), // file_count starts at 0
tagsJSON, collection.CreatedAt, collection.CreatedByUserID, collection.ModifiedAt,
collection.ModifiedByUserID, collection.Version, collection.State,
collection.TombstoneVersion, collection.TombstoneExpiry)
// 2. Insert owner access into BOTH user access tables
// 2 -> (1 of 2): Original table: supports queries across all access types
batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id
(user_id, modified_at, collection_id, access_type, permission_level, state)
VALUES (?, ?, ?, 'owner', ?, ?)`,
collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State)
// 2 -> (2 of 2): Access-type-specific table for efficient filtering
batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
(user_id, access_type, modified_at, collection_id, permission_level, state)
VALUES (?, 'owner', ?, ?, ?, ?)`,
collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State)
// 3. Insert into original parent index (still needed for cross-owner parent-child queries)
parentID := collection.ParentID
if !impl.isValidUUID(parentID) {
parentID = impl.nullParentUUID() // Use null UUID for root collections
}
batch.Query(`INSERT INTO collections_by_parent_id_with_asc_created_at_and_asc_collection_id
(parent_id, created_at, collection_id, owner_id, state)
VALUES (?, ?, ?, ?, ?)`,
parentID, collection.CreatedAt, collection.ID, collection.OwnerID, collection.State)
// 4. Insert into composite partition key table for optimized root collection queries
batch.Query(`INSERT INTO collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id
(parent_id, owner_id, created_at, collection_id, state)
VALUES (?, ?, ?, ?, ?)`,
parentID, collection.OwnerID, collection.CreatedAt, collection.ID, collection.State)
// 5. Insert into ancestor hierarchy table
ancestorEntries := impl.buildAncestorDepthEntries(collection.ID, collection.AncestorIDs)
for _, entry := range ancestorEntries {
batch.Query(`INSERT INTO collections_by_ancestor_id_with_asc_depth_and_asc_collection_id
(ancestor_id, depth, collection_id, state)
VALUES (?, ?, ?, ?)`,
entry.AncestorID, entry.Depth, entry.CollectionID, collection.State)
}
// 6. Insert into denormalized collections_by_tag_id table for each tag
for _, tag := range collection.Tags {
batch.Query(`INSERT INTO collections_by_tag_id
(tag_id, collection_id, owner_id, encrypted_name, collection_type,
encrypted_collection_key, encrypted_custom_icon, parent_id, ancestor_ids,
file_count, tags, created_at, created_by_user_id, modified_at, modified_by_user_id,
version, state, tombstone_version, tombstone_expiry,
created_from_ip_address, modified_from_ip_address, ip_anonymized_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tag.ID, collection.ID, collection.OwnerID, collection.EncryptedName, collection.CollectionType,
encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON,
collection.FileCount, tagsJSON, collection.CreatedAt, collection.CreatedByUserID,
collection.ModifiedAt, collection.ModifiedByUserID, collection.Version, collection.State,
collection.TombstoneVersion, collection.TombstoneExpiry,
nil, nil, nil) // IP tracking fields not yet in domain model
}
// 7. Insert members into normalized table AND both user access tables - WITH CONSISTENT VALIDATION
for i, member := range collection.Members {
impl.Logger.Info("processing member for creation",
zap.String("collection_id", collection.ID.String()),
zap.Int("member_index", i),
zap.String("recipient_id", member.RecipientID.String()),
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
zap.String("permission_level", member.PermissionLevel),
zap.Bool("is_inherited", member.IsInherited))
// Validate member data before insertion - CONSISTENT WITH UPDATE METHOD
if !impl.isValidUUID(member.RecipientID) {
return fmt.Errorf("invalid recipient ID for member %d", i)
}
if member.RecipientEmail == "" {
return fmt.Errorf("recipient email is required for member %d", i)
}
if member.PermissionLevel == "" {
return fmt.Errorf("permission level is required for member %d", i)
}
// FIXED: Only require encrypted collection key for non-owner members
// The owner has access to the collection key through their master key
isOwner := member.RecipientID == collection.OwnerID
if !isOwner && len(member.EncryptedCollectionKey) == 0 {
impl.Logger.Error("CRITICAL: encrypted collection key missing for shared member during creation",
zap.String("collection_id", collection.ID.String()),
zap.Int("member_index", i),
zap.String("recipient_id", member.RecipientID.String()),
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
zap.String("owner_id", collection.OwnerID.String()),
zap.Bool("is_owner", isOwner),
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
return fmt.Errorf("VALIDATION ERROR: encrypted collection key is required for shared member %d (recipient: %s, email: %s). This indicates a frontend bug or API misuse.", i, member.RecipientID.String(), validation.MaskEmail(member.RecipientEmail))
}
// Ensure member has an ID - CRITICAL: Set this before insertion
if !impl.isValidUUID(member.ID) {
member.ID = gocql.TimeUUID()
collection.Members[i].ID = member.ID // Update the collection's member slice
impl.Logger.Debug("generated member ID during creation",
zap.String("member_id", member.ID.String()),
zap.String("recipient_id", member.RecipientID.String()))
}
// Insert into normalized members table
batch.Query(`INSERT INTO collection_members_by_collection_id_and_recipient_id
(collection_id, recipient_id, member_id, recipient_email, granted_by_id,
encrypted_collection_key, permission_level, created_at,
is_inherited, inherited_from_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
collection.ID, member.RecipientID, member.ID, member.RecipientEmail,
member.GrantedByID, member.EncryptedCollectionKey,
member.PermissionLevel, member.CreatedAt,
member.IsInherited, member.InheritedFromID)
// Add member access to BOTH user access tables
// Original table: supports all-access-types queries
batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id
(user_id, modified_at, collection_id, access_type, permission_level, state)
VALUES (?, ?, ?, 'member', ?, ?)`,
member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State)
// NEW: Access-type-specific table for efficient member queries
batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
(user_id, access_type, modified_at, collection_id, permission_level, state)
VALUES (?, 'member', ?, ?, ?, ?)`,
member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State)
}
// Execute batch - this ensures all tables are updated atomically
if err := impl.Session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
impl.Logger.Error("failed to create collection",
zap.String("collection_id", collection.ID.String()),
zap.Error(err))
return fmt.Errorf("failed to create collection: %w", err)
}
impl.Logger.Info("collection created successfully in all tables",
zap.String("collection_id", collection.ID.String()),
zap.String("owner_id", collection.OwnerID.String()),
zap.Int("member_count", len(collection.Members)))
return nil
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,61 @@
// monorepo/cloud/maplefile-backend/internal/repo/filemetadata/anonymize_file_ips.go
package filemetadata
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// AnonymizeFileIPsByOwner immediately anonymizes all IP addresses for files owned by a specific user
// Used for GDPR right-to-be-forgotten implementation
func (impl *fileMetadataRepositoryImpl) AnonymizeFileIPsByOwner(ctx context.Context, ownerID gocql.UUID) (int, error) {
impl.Logger.Info("Anonymizing IPs for files owned by user (GDPR mode)",
zap.String("owner_id", ownerID.String()))
count := 0
// Query all files owned by this user
query := `SELECT id FROM maplefile.files_by_id WHERE owner_id = ? ALLOW FILTERING`
iter := impl.Session.Query(query, ownerID).WithContext(ctx).Iter()
var fileID gocql.UUID
var fileIDs []gocql.UUID
// Collect all file IDs first
for iter.Scan(&fileID) {
fileIDs = append(fileIDs, fileID)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("Error querying files by owner", zap.Error(err))
return count, err
}
// Anonymize IPs for each file
for _, fID := range fileIDs {
updateQuery := `
UPDATE maplefile.files_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(), fID).WithContext(ctx).Exec(); err != nil {
impl.Logger.Error("Failed to anonymize file IPs",
zap.String("file_id", fID.String()),
zap.Error(err))
continue // Best-effort: continue with next file
}
count++
}
impl.Logger.Info("✅ Successfully anonymized file IPs",
zap.String("owner_id", ownerID.String()),
zap.Int("files_anonymized", count))
return count, nil
}

View file

@ -0,0 +1,76 @@
// monorepo/cloud/maplefile-backend/internal/repo/filemetadata/anonymize_old_ips.go
package filemetadata
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// AnonymizeOldIPs anonymizes IP addresses in file tables older than the cutoff date
func (impl *fileMetadataRepositoryImpl) AnonymizeOldIPs(ctx context.Context, cutoffDate time.Time) (int, error) {
totalAnonymized := 0
// Anonymize files_by_id table (primary table)
count, err := impl.anonymizeFilesById(ctx, cutoffDate)
if err != nil {
impl.Logger.Error("Failed to anonymize files_by_id",
zap.Error(err),
zap.Time("cutoff_date", cutoffDate))
return totalAnonymized, err
}
totalAnonymized += count
impl.Logger.Info("IP anonymization completed for file tables",
zap.Int("total_anonymized", totalAnonymized),
zap.Time("cutoff_date", cutoffDate))
return totalAnonymized, nil
}
// anonymizeFilesById processes the files_by_id table
func (impl *fileMetadataRepositoryImpl) anonymizeFilesById(ctx context.Context, cutoffDate time.Time) (int, error) {
count := 0
// Query all files (efficient primary key scan, no ALLOW FILTERING)
query := `SELECT id, created_at, ip_anonymized_at FROM maplefile.files_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.files_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 file record",
zap.String("file_id", id.String()),
zap.Error(err))
continue
}
count++
}
}
if err := iter.Close(); err != nil {
impl.Logger.Error("Error during files_by_id iteration", zap.Error(err))
return count, err
}
impl.Logger.Debug("Anonymized files_by_id table",
zap.Int("count", count),
zap.Time("cutoff_date", cutoffDate))
return count, nil
}

View file

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

View file

@ -0,0 +1,38 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/check.go
package filemetadata
import (
"fmt"
"github.com/gocql/gocql"
)
func (impl *fileMetadataRepositoryImpl) CheckIfExistsByID(id gocql.UUID) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM maplefile.files_by_id WHERE id = ?`
if err := impl.Session.Query(query, id).Scan(&count); err != nil {
return false, fmt.Errorf("failed to check file existence: %w", err)
}
return count > 0, nil
}
func (impl *fileMetadataRepositoryImpl) CheckIfUserHasAccess(fileID gocql.UUID, userID gocql.UUID) (bool, error) {
// Check if user has access via the user sync table
var count int
query := `SELECT COUNT(*) FROM maplefile.files_by_user
WHERE user_id = ? AND id = ? LIMIT 1 ALLOW FILTERING`
err := impl.Session.Query(query, userID, fileID).Scan(&count)
if err != nil {
if err == gocql.ErrNotFound {
return false, nil
}
return false, fmt.Errorf("failed to check file access: %w", err)
}
return count > 0, nil
}

View file

@ -0,0 +1,138 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/count.go
package filemetadata
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
// CountFilesByUser counts all active files accessible to the user
// accessibleCollectionIDs should include all collections the user owns or has access to
func (impl *fileMetadataRepositoryImpl) CountFilesByUser(ctx context.Context, userID gocql.UUID, accessibleCollectionIDs []gocql.UUID) (int, error) {
if len(accessibleCollectionIDs) == 0 {
// No accessible collections, return 0
impl.Logger.Debug("no accessible collections provided for file count",
zap.String("user_id", userID.String()))
return 0, nil
}
// Create a map for efficient collection access checking
accessibleCollections := make(map[gocql.UUID]bool)
for _, cid := range accessibleCollectionIDs {
accessibleCollections[cid] = true
}
// Query files for the user using the user sync table
query := `SELECT id, collection_id, state FROM maplefile.files_by_user
WHERE user_id = ?`
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
count := 0
var fileID, collectionID gocql.UUID
var state string
for iter.Scan(&fileID, &collectionID, &state) {
// Only count files from accessible collections
if !accessibleCollections[collectionID] {
continue
}
// Only count active files
if state != dom_file.FileStateActive {
continue
}
count++
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to count files by user",
zap.String("user_id", userID.String()),
zap.Int("accessible_collections_count", len(accessibleCollectionIDs)),
zap.Error(err))
return 0, fmt.Errorf("failed to count files by user: %w", err)
}
impl.Logger.Debug("counted files by user successfully",
zap.String("user_id", userID.String()),
zap.Int("accessible_collections_count", len(accessibleCollectionIDs)),
zap.Int("file_count", count))
return count, nil
}
// CountFilesByOwner counts all active files owned by the user (alternative approach)
func (impl *fileMetadataRepositoryImpl) CountFilesByOwner(ctx context.Context, ownerID gocql.UUID) (int, error) {
// Query files owned by the user using the owner table
query := `SELECT id, state FROM maplefile.files_by_owner
WHERE owner_id = ?`
iter := impl.Session.Query(query, ownerID).WithContext(ctx).Iter()
count := 0
var fileID gocql.UUID
var state string
for iter.Scan(&fileID, &state) {
// Only count active files
if state != dom_file.FileStateActive {
continue
}
count++
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to count files by owner",
zap.String("owner_id", ownerID.String()),
zap.Error(err))
return 0, fmt.Errorf("failed to count files by owner: %w", err)
}
impl.Logger.Debug("counted files by owner successfully",
zap.String("owner_id", ownerID.String()),
zap.Int("file_count", count))
return count, nil
}
// CountFilesByCollection counts active files in a specific collection
func (impl *fileMetadataRepositoryImpl) CountFilesByCollection(ctx context.Context, collectionID gocql.UUID) (int, error) {
// Query files in the collection using the collection table
query := `SELECT id, state FROM maplefile.files_by_collection
WHERE collection_id = ?`
iter := impl.Session.Query(query, collectionID).WithContext(ctx).Iter()
count := 0
var fileID gocql.UUID
var state string
for iter.Scan(&fileID, &state) {
// Only count active files
if state != dom_file.FileStateActive {
continue
}
count++
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to count files by collection",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return 0, fmt.Errorf("failed to count files by collection: %w", err)
}
impl.Logger.Debug("counted files by collection successfully",
zap.String("collection_id", collectionID.String()),
zap.Int("file_count", count))
return count, nil
}

View file

@ -0,0 +1,327 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/create.go
package filemetadata
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) Create(file *dom_file.File) error {
if file == nil {
return fmt.Errorf("file cannot be nil")
}
if !impl.isValidUUID(file.ID) {
return fmt.Errorf("file ID is required")
}
if !impl.isValidUUID(file.CollectionID) {
return fmt.Errorf("collection ID is required")
}
if !impl.isValidUUID(file.OwnerID) {
return fmt.Errorf("owner ID is required")
}
// Set creation timestamp if not set
if file.CreatedAt.IsZero() {
file.CreatedAt = time.Now()
}
if file.ModifiedAt.IsZero() {
file.ModifiedAt = file.CreatedAt
}
// Ensure state is set
if file.State == "" {
file.State = dom_file.FileStateActive
}
// Serialize encrypted file key
encryptedKeyJSON, err := impl.serializeEncryptedFileKey(file.EncryptedFileKey)
if err != nil {
return fmt.Errorf("failed to serialize encrypted file key: %w", err)
}
// Serialize tags
tagsJSON, err := impl.serializeTags(file.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 maplefile.files_by_id
(id, collection_id, owner_id, encrypted_metadata, encrypted_file_key, encryption_version,
encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes, tags,
created_at, created_by_user_id, modified_at, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.ID, file.CollectionID, file.OwnerID, file.EncryptedMetadata, encryptedKeyJSON,
file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, tagsJSON, file.CreatedAt, file.CreatedByUserID,
file.ModifiedAt, file.ModifiedByUserID, file.Version, file.State,
file.TombstoneVersion, file.TombstoneExpiry)
// 2. Insert into collection table
batch.Query(`INSERT INTO maplefile.files_by_collection
(collection_id, modified_at, id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.CollectionID, file.ModifiedAt, file.ID, file.OwnerID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 3. Insert into owner table
batch.Query(`INSERT INTO maplefile.files_by_owner
(owner_id, modified_at, id, collection_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.OwnerID, file.ModifiedAt, file.ID, file.CollectionID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 4. Insert into created_by table
batch.Query(`INSERT INTO maplefile.files_by_creator
(created_by_user_id, created_at, id, collection_id, owner_id, encrypted_metadata,
encrypted_file_key, encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
modified_at, modified_by_user_id, version, state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.CreatedByUserID, file.CreatedAt, file.ID, file.CollectionID, file.OwnerID,
file.EncryptedMetadata, encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.ModifiedAt, file.ModifiedByUserID, file.Version,
file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 5. Insert into user sync table (for owner and any collection members)
batch.Query(`INSERT INTO maplefile.files_by_user
(user_id, modified_at, id, collection_id, owner_id, encrypted_metadata,
encrypted_file_key, encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
tags, created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.OwnerID, file.ModifiedAt, file.ID, file.CollectionID, file.OwnerID,
file.EncryptedMetadata, encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, tagsJSON, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 6. Insert into denormalized files_by_tag_id table for each tag
for _, tag := range file.Tags {
batch.Query(`INSERT INTO maplefile.files_by_tag_id
(tag_id, file_id, collection_id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key,
encrypted_thumbnail_size_in_bytes, tag_ids, 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, file.ID, file.CollectionID, file.OwnerID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes,
file.EncryptedThumbnailObjectKey, file.EncryptedThumbnailSizeInBytes,
tagsJSON, file.CreatedAt, file.CreatedByUserID, file.ModifiedAt,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion,
file.TombstoneExpiry,
nil, nil, nil) // IP tracking fields not yet in domain model
}
// Execute batch
if err := impl.Session.ExecuteBatch(batch); err != nil {
impl.Logger.Error("failed to create file",
zap.String("file_id", file.ID.String()),
zap.Error(err))
return fmt.Errorf("failed to create file: %w", err)
}
// Increment collection file count for active files
if file.State == dom_file.FileStateActive {
if err := impl.CollectionRepo.IncrementFileCount(context.Background(), file.CollectionID); err != nil {
impl.Logger.Error("failed to increment collection file count",
zap.String("file_id", file.ID.String()),
zap.String("collection_id", file.CollectionID.String()),
zap.Error(err))
// Don't fail the entire operation if count update fails
}
}
impl.Logger.Info("file created successfully",
zap.String("file_id", file.ID.String()),
zap.String("collection_id", file.CollectionID.String()))
return nil
}
func (impl *fileMetadataRepositoryImpl) CreateMany(files []*dom_file.File) error {
if len(files) == 0 {
return nil
}
batch := impl.Session.NewBatch(gocql.LoggedBatch)
for _, file := range files {
if file == nil {
continue
}
// Set timestamps if not set
if file.CreatedAt.IsZero() {
file.CreatedAt = time.Now()
}
if file.ModifiedAt.IsZero() {
file.ModifiedAt = file.CreatedAt
}
if file.State == "" {
file.State = dom_file.FileStateActive
}
encryptedKeyJSON, err := impl.serializeEncryptedFileKey(file.EncryptedFileKey)
if err != nil {
return fmt.Errorf("failed to serialize encrypted file key for file %s: %w", file.ID.String(), err)
}
tagsJSON, err := impl.serializeTags(file.Tags)
if err != nil {
return fmt.Errorf("failed to serialize tags for file %s: %w", file.ID.String(), err)
}
// Add to all 5 tables (same as Create but in batch)
batch.Query(`INSERT INTO maplefile.files_by_id
(id, collection_id, owner_id, encrypted_metadata, encrypted_file_key, encryption_version,
encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes, tags,
created_at, created_by_user_id, modified_at, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.ID, file.CollectionID, file.OwnerID, file.EncryptedMetadata, encryptedKeyJSON,
file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, tagsJSON, file.CreatedAt, file.CreatedByUserID,
file.ModifiedAt, file.ModifiedByUserID, file.Version, file.State,
file.TombstoneVersion, file.TombstoneExpiry)
// 2. Insert into collection table
batch.Query(`INSERT INTO maplefile.files_by_collection
(collection_id, modified_at, id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.CollectionID, file.ModifiedAt, file.ID, file.OwnerID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 3. Insert into owner table
batch.Query(`INSERT INTO maplefile.files_by_owner
(owner_id, modified_at, id, collection_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.OwnerID, file.ModifiedAt, file.ID, file.CollectionID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 4. Insert into created_by table
batch.Query(`INSERT INTO maplefile.files_by_creator
(created_by_user_id, created_at, id, collection_id, owner_id, encrypted_metadata,
encrypted_file_key, encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
modified_at, modified_by_user_id, version, state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.CreatedByUserID, file.CreatedAt, file.ID, file.CollectionID, file.OwnerID,
file.EncryptedMetadata, encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.ModifiedAt, file.ModifiedByUserID, file.Version,
file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 5. Insert into user sync table (for owner and any collection members)
batch.Query(`INSERT INTO maplefile.files_by_user
(user_id, modified_at, id, collection_id, owner_id, encrypted_metadata,
encrypted_file_key, encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
tags, created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.OwnerID, file.ModifiedAt, file.ID, file.CollectionID, file.OwnerID,
file.EncryptedMetadata, encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, tagsJSON, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 6. Insert into denormalized files_by_tag_id table for each tag
for _, tag := range file.Tags {
batch.Query(`INSERT INTO maplefile.files_by_tag_id
(tag_id, file_id, collection_id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key,
encrypted_thumbnail_size_in_bytes, tag_ids, 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, file.ID, file.CollectionID, file.OwnerID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes,
file.EncryptedThumbnailObjectKey, file.EncryptedThumbnailSizeInBytes,
tagsJSON, file.CreatedAt, file.CreatedByUserID, file.ModifiedAt,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion,
file.TombstoneExpiry,
nil, nil, nil) // IP tracking fields not yet in domain model
}
}
if err := impl.Session.ExecuteBatch(batch); err != nil {
impl.Logger.Error("failed to create multiple files", zap.Error(err))
return fmt.Errorf("failed to create multiple files: %w", err)
}
// Increment collection file counts for active files
// Group by collection to minimize updates
collectionCounts := make(map[gocql.UUID]int)
for _, file := range files {
if file != nil && file.State == dom_file.FileStateActive {
collectionCounts[file.CollectionID]++
}
}
for collectionID, count := range collectionCounts {
for i := 0; i < count; i++ {
if err := impl.CollectionRepo.IncrementFileCount(context.Background(), collectionID); err != nil {
impl.Logger.Error("failed to increment collection file count",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
// Don't fail the entire operation if count update fails
}
}
}
impl.Logger.Info("multiple files created successfully", zap.Int("count", len(files)))
return nil
}

View file

@ -0,0 +1,127 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/delete.go
package filemetadata
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) SoftDelete(id gocql.UUID) error {
file, err := impl.Get(id)
if err != nil {
return fmt.Errorf("failed to get file for soft delete: %w", err)
}
if file == nil {
return fmt.Errorf("file not found")
}
// Validate state transition
if err := dom_file.IsValidStateTransition(file.State, dom_file.FileStateDeleted); err != nil {
return fmt.Errorf("invalid state transition: %w", err)
}
// Update file state
file.State = dom_file.FileStateDeleted
file.ModifiedAt = time.Now()
file.Version++
file.TombstoneVersion = file.Version
file.TombstoneExpiry = time.Now().Add(30 * 24 * time.Hour) // 30 days
return impl.Update(file)
}
func (impl *fileMetadataRepositoryImpl) SoftDeleteMany(ids []gocql.UUID) error {
for _, id := range ids {
if err := impl.SoftDelete(id); err != nil {
impl.Logger.Warn("failed to soft delete file",
zap.String("file_id", id.String()),
zap.Error(err))
}
}
return nil
}
func (impl *fileMetadataRepositoryImpl) HardDelete(id gocql.UUID) error {
file, err := impl.Get(id)
if err != nil {
return fmt.Errorf("failed to get file for hard delete: %w", err)
}
if file == nil {
return fmt.Errorf("file not found")
}
batch := impl.Session.NewBatch(gocql.LoggedBatch)
// 1. Delete from main table
batch.Query(`DELETE FROM maplefile.files_by_id WHERE id = ?`, id)
// 2. Delete from collection table
batch.Query(`DELETE FROM maplefile.files_by_collection
WHERE collection_id = ? AND modified_at = ? AND id = ?`,
file.CollectionID, file.ModifiedAt, id)
// 3. Delete from owner table
batch.Query(`DELETE FROM maplefile.files_by_owner
WHERE owner_id = ? AND modified_at = ? AND id = ?`,
file.OwnerID, file.ModifiedAt, id)
// 4. Delete from created_by table
batch.Query(`DELETE FROM maplefile.files_by_creator
WHERE created_by_user_id = ? AND created_at = ? AND id = ?`,
file.CreatedByUserID, file.CreatedAt, id)
// 5. Delete from user sync table
batch.Query(`DELETE FROM maplefile.files_by_user
WHERE user_id = ? AND modified_at = ? AND id = ?`,
file.OwnerID, file.ModifiedAt, id)
// 6. Delete from denormalized files_by_tag_id table for all tags
for _, tag := range file.Tags {
batch.Query(`DELETE FROM maplefile.files_by_tag_id
WHERE tag_id = ? AND file_id = ?`,
tag.ID, id)
}
// Execute batch
if err := impl.Session.ExecuteBatch(batch); err != nil {
impl.Logger.Error("failed to hard delete file",
zap.String("file_id", id.String()),
zap.Error(err))
return fmt.Errorf("failed to hard delete file: %w", err)
}
// Decrement collection file count if the file was active
if file.State == dom_file.FileStateActive {
if err := impl.CollectionRepo.DecrementFileCount(context.Background(), file.CollectionID); err != nil {
impl.Logger.Error("failed to decrement collection file count",
zap.String("file_id", id.String()),
zap.String("collection_id", file.CollectionID.String()),
zap.Error(err))
// Don't fail the entire operation if count update fails
}
}
impl.Logger.Info("file hard deleted successfully",
zap.String("file_id", id.String()))
return nil
}
func (impl *fileMetadataRepositoryImpl) HardDeleteMany(ids []gocql.UUID) error {
for _, id := range ids {
if err := impl.HardDelete(id); err != nil {
impl.Logger.Warn("failed to hard delete file",
zap.String("file_id", id.String()),
zap.Error(err))
}
}
return nil
}

View file

@ -0,0 +1,217 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/get.go
package filemetadata
import (
"fmt"
"sync"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) Get(id gocql.UUID) (*dom_file.File, error) {
var (
collectionID, ownerID, createdByUserID, modifiedByUserID gocql.UUID
encryptedMetadata, encryptedKeyJSON, encryptionVersion string
encryptedHash, encryptedFileObjectKey string
encryptedThumbnailObjectKey string
encryptedFileSizeInBytes, encryptedThumbnailSizeInBytes int64
tagsJSON string
createdAt, modifiedAt, tombstoneExpiry time.Time
version, tombstoneVersion uint64
state string
)
query := `SELECT id, collection_id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes, tags,
created_at, created_by_user_id, modified_at, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry
FROM maplefile.files_by_id WHERE id = ?`
err := impl.Session.Query(query, id).Scan(
&id, &collectionID, &ownerID, &encryptedMetadata, &encryptedKeyJSON,
&encryptionVersion, &encryptedHash, &encryptedFileObjectKey, &encryptedFileSizeInBytes,
&encryptedThumbnailObjectKey, &encryptedThumbnailSizeInBytes, &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 file: %w", err)
}
// Deserialize encrypted file key
encryptedFileKey, err := impl.deserializeEncryptedFileKey(encryptedKeyJSON)
if err != nil {
return nil, fmt.Errorf("failed to deserialize encrypted file key: %w", err)
}
// Deserialize tags
tags, err := impl.deserializeTags(tagsJSON)
if err != nil {
return nil, fmt.Errorf("failed to deserialize tags: %w", err)
}
file := &dom_file.File{
ID: id,
CollectionID: collectionID,
OwnerID: ownerID,
EncryptedMetadata: encryptedMetadata,
EncryptedFileKey: encryptedFileKey,
EncryptionVersion: encryptionVersion,
EncryptedHash: encryptedHash,
EncryptedFileObjectKey: encryptedFileObjectKey,
EncryptedFileSizeInBytes: encryptedFileSizeInBytes,
EncryptedThumbnailObjectKey: encryptedThumbnailObjectKey,
EncryptedThumbnailSizeInBytes: encryptedThumbnailSizeInBytes,
Tags: tags,
CreatedAt: createdAt,
CreatedByUserID: createdByUserID,
ModifiedAt: modifiedAt,
ModifiedByUserID: modifiedByUserID,
Version: version,
State: state,
TombstoneVersion: tombstoneVersion,
TombstoneExpiry: tombstoneExpiry,
}
return file, nil
}
func (impl *fileMetadataRepositoryImpl) GetByIDs(ids []gocql.UUID) ([]*dom_file.File, error) {
if len(ids) == 0 {
return []*dom_file.File{}, nil
}
// Use a buffered channel to collect results from goroutines
resultsChan := make(chan *dom_file.File, len(ids))
var wg sync.WaitGroup
// Launch a goroutine for each ID lookup
for _, id := range ids {
wg.Add(1)
go func(id gocql.UUID) {
defer wg.Done()
// Call the existing state-aware Get method
file, err := impl.Get(id)
if err != nil {
impl.Logger.Warn("failed to get file by ID",
zap.String("file_id", id.String()),
zap.Error(err))
// Send nil on error to indicate failure/absence for this ID
resultsChan <- nil
return
}
// Get returns nil for ErrNotFound or inactive state when stateAware is true.
// Send the potentially nil file result to the channel.
resultsChan <- file
}(id) // Pass id into the closure
}
// Goroutine to close the channel once all workers are done
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect results from the channel
var files []*dom_file.File
for file := range resultsChan {
// Only append non-nil files (found and active)
if file != nil {
files = append(files, file)
}
}
// The original function logs warnings for errors but doesn't return an error
// from GetByIDs itself. We maintain this behavior.
return files, nil
}
func (impl *fileMetadataRepositoryImpl) GetByCollection(collectionID gocql.UUID) ([]*dom_file.File, error) {
var fileIDs []gocql.UUID
query := `SELECT id FROM maplefile.files_by_collection
WHERE collection_id = ?`
iter := impl.Session.Query(query, collectionID).Iter()
var fileID gocql.UUID
for iter.Scan(&fileID) {
fileIDs = append(fileIDs, fileID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get files by collection: %w", err)
}
return impl.loadMultipleFiles(fileIDs)
}
func (impl *fileMetadataRepositoryImpl) loadMultipleFiles(fileIDs []gocql.UUID) ([]*dom_file.File, error) {
if len(fileIDs) == 0 {
return []*dom_file.File{}, nil
}
// Use a buffered channel to collect results from goroutines
// We expect up to len(fileIDs) results, some of which might be nil.
resultsChan := make(chan *dom_file.File, len(fileIDs))
var wg sync.WaitGroup
// Launch a goroutine for each ID lookup
for _, id := range fileIDs {
wg.Add(1)
go func(id gocql.UUID) {
defer wg.Done()
// Call the existing state-aware Get method
// This method returns nil if the file is not found, or if it's
// found but not in the 'active' state.
file, err := impl.Get(id)
if err != nil {
// Log the error but continue processing other IDs.
impl.Logger.Warn("failed to load file",
zap.String("file_id", id.String()),
zap.Error(err))
// Send nil on error, consistent with how Get returns nil for not found/inactive.
resultsChan <- nil
return
}
// Get returns nil for ErrNotFound or inactive state when stateAware is true.
// Send the potentially nil file result to the channel.
resultsChan <- file
}(id) // Pass id into the closure
}
// Goroutine to close the channel once all workers are done
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect results from the channel
var files []*dom_file.File
for file := range resultsChan {
// Only append non-nil files (found and active, or found but error logged)
if file != nil {
files = append(files, file)
}
}
// The original function logged warnings for errors but didn't return an error
// from loadMultipleFiles itself. We maintain this behavior.
return files, nil
}

View file

@ -0,0 +1,29 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/get_by_created_by_user_id.go
package filemetadata
import (
"fmt"
"github.com/gocql/gocql"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) GetByCreatedByUserID(createdByUserID gocql.UUID) ([]*dom_file.File, error) {
var fileIDs []gocql.UUID
query := `SELECT id FROM maplefile.files_by_creator
WHERE created_by_user_id = ?`
iter := impl.Session.Query(query, createdByUserID).Iter()
var fileID gocql.UUID
for iter.Scan(&fileID) {
fileIDs = append(fileIDs, fileID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get files by creator: %w", err)
}
return impl.loadMultipleFiles(fileIDs)
}

View file

@ -0,0 +1,29 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/get_by_owner_id.go
package filemetadata
import (
"fmt"
"github.com/gocql/gocql"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) GetByOwnerID(ownerID gocql.UUID) ([]*dom_file.File, error) {
var fileIDs []gocql.UUID
query := `SELECT id FROM maplefile.files_by_owner
WHERE owner_id = ?`
iter := impl.Session.Query(query, ownerID).Iter()
var fileID gocql.UUID
for iter.Scan(&fileID) {
fileIDs = append(fileIDs, fileID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get files by owner: %w", err)
}
return impl.loadMultipleFiles(fileIDs)
}

View file

@ -0,0 +1,68 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/impl.go
package filemetadata
import (
"encoding/json"
"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"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
)
type fileMetadataRepositoryImpl struct {
Logger *zap.Logger
Session *gocql.Session
CollectionRepo dom_collection.CollectionRepository
}
func NewRepository(appCfg *config.Configuration, session *gocql.Session, loggerp *zap.Logger, collectionRepo dom_collection.CollectionRepository) dom_file.FileMetadataRepository {
loggerp = loggerp.Named("FileMetadataRepository")
return &fileMetadataRepositoryImpl{
Logger: loggerp,
Session: session,
CollectionRepo: collectionRepo,
}
}
// Helper functions for JSON serialization
func (impl *fileMetadataRepositoryImpl) serializeEncryptedFileKey(key crypto.EncryptedFileKey) (string, error) {
data, err := json.Marshal(key)
return string(data), err
}
func (impl *fileMetadataRepositoryImpl) deserializeEncryptedFileKey(data string) (crypto.EncryptedFileKey, error) {
if data == "" {
return crypto.EncryptedFileKey{}, nil
}
var key crypto.EncryptedFileKey
err := json.Unmarshal([]byte(data), &key)
return key, err
}
func (impl *fileMetadataRepositoryImpl) serializeTags(tags []tag.EmbeddedTag) (string, error) {
if len(tags) == 0 {
return "[]", nil
}
data, err := json.Marshal(tags)
return string(data), err
}
func (impl *fileMetadataRepositoryImpl) 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 *fileMetadataRepositoryImpl) isValidUUID(id gocql.UUID) bool {
return id.String() != "00000000-0000-0000-0000-000000000000"
}

View file

@ -0,0 +1,57 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/list_by_tag_id.go
package filemetadata
import (
"context"
"fmt"
"github.com/gocql/gocql"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
"go.uber.org/zap"
)
// ListByTagID retrieves all files that have the specified tag assigned
// Uses the denormalized files_by_tag_id table for efficient lookups
func (impl *fileMetadataRepositoryImpl) ListByTagID(ctx context.Context, tagID gocql.UUID) ([]*dom_file.File, error) {
impl.Logger.Info("🏷️ REPO: Listing files by tag ID",
zap.String("tag_id", tagID.String()))
var fileIDs []gocql.UUID
// Query the denormalized table
query := `SELECT file_id FROM maplefile.files_by_tag_id WHERE tag_id = ?`
iter := impl.Session.Query(query, tagID).WithContext(ctx).Iter()
var fileID gocql.UUID
for iter.Scan(&fileID) {
fileIDs = append(fileIDs, fileID)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("🏷️ REPO: Failed to query files by tag",
zap.String("tag_id", tagID.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to list files by tag: %w", err)
}
impl.Logger.Info("🏷️ REPO: Found file IDs for tag",
zap.String("tag_id", tagID.String()),
zap.Int("count", len(fileIDs)))
// Load full file details using existing helper method
// This will filter to only active files
files, err := impl.loadMultipleFiles(fileIDs)
if err != nil {
impl.Logger.Error("🏷️ REPO: Failed to load files",
zap.String("tag_id", tagID.String()),
zap.Error(err))
return nil, err
}
impl.Logger.Info("🏷️ REPO: Successfully loaded files by tag",
zap.String("tag_id", tagID.String()),
zap.Int("active_count", len(files)))
return files, nil
}

View file

@ -0,0 +1,135 @@
// cloud/maplefile-backend/internal/maplefile/repo/filemetadata/list_recent_files.go
package filemetadata
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
// Using types from dom_file package (defined in model.go)
// ListRecentFiles retrieves recent files with pagination for the specified user and accessible collections
func (impl *fileMetadataRepositoryImpl) ListRecentFiles(ctx context.Context, userID gocql.UUID, cursor *dom_file.RecentFilesCursor, limit int64, accessibleCollectionIDs []gocql.UUID) (*dom_file.RecentFilesResponse, error) {
if len(accessibleCollectionIDs) == 0 {
// No accessible collections, return empty response
return &dom_file.RecentFilesResponse{
Files: []dom_file.RecentFilesItem{},
HasMore: false,
}, nil
}
// Build query based on cursor
var query string
var args []any
if cursor == nil {
// Initial request - get most recent files for user
query = `SELECT id, collection_id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_size_in_bytes, encrypted_thumbnail_size_in_bytes,
tags, created_at, modified_at, version, state
FROM maplefile.files_by_user
WHERE user_id = ? LIMIT ?`
args = []any{userID, limit}
} else {
// Paginated request - get files modified before cursor
query = `SELECT id, collection_id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_size_in_bytes, encrypted_thumbnail_size_in_bytes,
tags, created_at, modified_at, version, state
FROM maplefile.files_by_user
WHERE user_id = ? AND (modified_at, id) < (?, ?) LIMIT ?`
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
}
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
var recentItems []dom_file.RecentFilesItem
var lastModified time.Time
var lastID gocql.UUID
var (
fileID gocql.UUID
collectionID, ownerID gocql.UUID
encryptedMetadata, encryptedFileKey, encryptionVersion, encryptedHash string
encryptedFileSizeInBytes, encryptedThumbnailSizeInBytes int64
tagsJSON string
createdAt, modifiedAt time.Time
version uint64
state string
)
// Filter files by accessible collections and only include active files
accessibleCollections := make(map[gocql.UUID]bool)
for _, cid := range accessibleCollectionIDs {
accessibleCollections[cid] = true
}
for iter.Scan(&fileID, &collectionID, &ownerID, &encryptedMetadata, &encryptedFileKey,
&encryptionVersion, &encryptedHash, &encryptedFileSizeInBytes, &encryptedThumbnailSizeInBytes,
&tagsJSON, &createdAt, &modifiedAt, &version, &state) {
// Only include files from accessible collections
if !accessibleCollections[collectionID] {
continue
}
// Only include active files (exclude deleted, archived, pending)
if state != dom_file.FileStateActive {
continue
}
// Deserialize tags
tags, _ := impl.deserializeTags(tagsJSON)
recentItem := dom_file.RecentFilesItem{
ID: fileID,
CollectionID: collectionID,
OwnerID: ownerID,
EncryptedMetadata: encryptedMetadata,
EncryptedFileKey: encryptedFileKey,
EncryptionVersion: encryptionVersion,
EncryptedHash: encryptedHash,
EncryptedFileSizeInBytes: encryptedFileSizeInBytes,
EncryptedThumbnailSizeInBytes: encryptedThumbnailSizeInBytes,
Tags: tags,
CreatedAt: createdAt,
ModifiedAt: modifiedAt,
Version: version,
State: state,
}
recentItems = append(recentItems, recentItem)
lastModified = modifiedAt
lastID = fileID
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get recent files: %w", err)
}
// Prepare response
response := &dom_file.RecentFilesResponse{
Files: recentItems,
HasMore: len(recentItems) == int(limit),
}
// Set next cursor if there are more results
if response.HasMore {
response.NextCursor = &dom_file.RecentFilesCursor{
LastModified: lastModified,
LastID: lastID,
}
}
impl.Logger.Debug("recent files retrieved",
zap.String("user_id", userID.String()),
zap.Int("file_count", len(recentItems)),
zap.Bool("has_more", response.HasMore))
return response, nil
}

View file

@ -0,0 +1,109 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/list_sync_data.go
package filemetadata
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) ListSyncData(ctx context.Context, userID gocql.UUID, cursor *dom_file.FileSyncCursor, limit int64, accessibleCollectionIDs []gocql.UUID) (*dom_file.FileSyncResponse, error) {
if len(accessibleCollectionIDs) == 0 {
// No accessible collections, return empty response
return &dom_file.FileSyncResponse{
Files: []dom_file.FileSyncItem{},
HasMore: false,
}, nil
}
// Build query based on cursor
var query string
var args []any
if cursor == nil {
// Initial sync - get all files for user
query = `SELECT id, collection_id, version, modified_at, state, tombstone_version, tombstone_expiry, encrypted_file_size_in_bytes
FROM maplefile.files_by_user
WHERE user_id = ? LIMIT ?`
args = []any{userID, limit}
} else {
// Incremental sync - get files modified after cursor
query = `SELECT id, collection_id, version, modified_at, state, tombstone_version, tombstone_expiry, encrypted_file_size_in_bytes
FROM maplefile.files_by_user
WHERE user_id = ? AND (modified_at, id) > (?, ?) LIMIT ?`
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
}
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
var syncItems []dom_file.FileSyncItem
var lastModified time.Time
var lastID gocql.UUID
var (
fileID gocql.UUID
collectionID gocql.UUID
version, tombstoneVersion uint64
modifiedAt, tombstoneExpiry time.Time
state string
encryptedFileSizeInBytes int64
)
// Filter files by accessible collections
accessibleCollections := make(map[gocql.UUID]bool)
for _, cid := range accessibleCollectionIDs {
accessibleCollections[cid] = true
}
for iter.Scan(&fileID, &collectionID, &version, &modifiedAt, &state, &tombstoneVersion, &tombstoneExpiry, &encryptedFileSizeInBytes) {
// Only include files from accessible collections
if !accessibleCollections[collectionID] {
continue
}
syncItem := dom_file.FileSyncItem{
ID: fileID,
CollectionID: collectionID,
Version: version,
ModifiedAt: modifiedAt,
State: state,
TombstoneVersion: tombstoneVersion,
TombstoneExpiry: tombstoneExpiry,
EncryptedFileSizeInBytes: encryptedFileSizeInBytes,
}
syncItems = append(syncItems, syncItem)
lastModified = modifiedAt
lastID = fileID
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get file sync data: %w", err)
}
// Prepare response
response := &dom_file.FileSyncResponse{
Files: syncItems,
HasMore: len(syncItems) == int(limit),
}
// Set next cursor if there are more results
if response.HasMore {
response.NextCursor = &dom_file.FileSyncCursor{
LastModified: lastModified,
LastID: lastID,
}
}
impl.Logger.Debug("file sync data retrieved",
zap.String("user_id", userID.String()),
zap.Int("file_count", len(syncItems)),
zap.Bool("has_more", response.HasMore))
return response, nil
}

View file

@ -0,0 +1,15 @@
package filemetadata
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"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
// ProvideRepository provides a file metadata repository for Wire DI
func ProvideRepository(cfg *config.Config, session *gocql.Session, logger *zap.Logger, collectionRepo dom_collection.CollectionRepository) dom_file.FileMetadataRepository {
return NewRepository(cfg, session, logger, collectionRepo)
}

View file

@ -0,0 +1,48 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/restore.go
package filemetadata
import (
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) Restore(id gocql.UUID) error {
file, err := impl.Get(id)
if err != nil {
return fmt.Errorf("failed to get file for restore: %w", err)
}
if file == nil {
return fmt.Errorf("file not found")
}
// Validate state transition
if err := dom_file.IsValidStateTransition(file.State, dom_file.FileStateActive); err != nil {
return fmt.Errorf("invalid state transition: %w", err)
}
// Update file state
file.State = dom_file.FileStateActive
file.ModifiedAt = time.Now()
file.Version++
file.TombstoneVersion = 0
file.TombstoneExpiry = time.Time{}
return impl.Update(file)
}
func (impl *fileMetadataRepositoryImpl) RestoreMany(ids []gocql.UUID) error {
for _, id := range ids {
if err := impl.Restore(id); err != nil {
impl.Logger.Warn("failed to restore file",
zap.String("file_id", id.String()),
zap.Error(err))
}
}
return nil
}

View file

@ -0,0 +1,204 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/storage_size.go
package filemetadata
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
// GetTotalStorageSizeByOwner calculates total storage size for all active files owned by the user
func (impl *fileMetadataRepositoryImpl) GetTotalStorageSizeByOwner(ctx context.Context, ownerID gocql.UUID) (int64, error) {
// Query files owned by the user using the owner table
query := `SELECT id, state, encrypted_file_size_in_bytes, encrypted_thumbnail_size_in_bytes
FROM maplefile.files_by_owner
WHERE owner_id = ?`
iter := impl.Session.Query(query, ownerID).WithContext(ctx).Iter()
var totalSize int64
var fileID gocql.UUID
var state string
var fileSize, thumbnailSize int64
for iter.Scan(&fileID, &state, &fileSize, &thumbnailSize) {
// Only include active files in size calculation
if state != dom_file.FileStateActive {
continue
}
// Add both file and thumbnail sizes
totalSize += fileSize + thumbnailSize
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to calculate total storage size by owner",
zap.String("owner_id", ownerID.String()),
zap.Error(err))
return 0, fmt.Errorf("failed to calculate total storage size by owner: %w", err)
}
impl.Logger.Debug("calculated total storage size by owner successfully",
zap.String("owner_id", ownerID.String()),
zap.Int64("total_size_bytes", totalSize))
return totalSize, nil
}
// GetTotalStorageSizeByUser calculates total storage size for all active files accessible to the user
// accessibleCollectionIDs should include all collections the user owns or has access to
func (impl *fileMetadataRepositoryImpl) GetTotalStorageSizeByUser(ctx context.Context, userID gocql.UUID, accessibleCollectionIDs []gocql.UUID) (int64, error) {
if len(accessibleCollectionIDs) == 0 {
// No accessible collections, return 0
impl.Logger.Debug("no accessible collections provided for storage size calculation",
zap.String("user_id", userID.String()))
return 0, nil
}
// Create a map for efficient collection access checking
accessibleCollections := make(map[gocql.UUID]bool)
for _, cid := range accessibleCollectionIDs {
accessibleCollections[cid] = true
}
// Query files for the user using the user sync table
query := `SELECT id, collection_id, state, encrypted_file_size_in_bytes, encrypted_thumbnail_size_in_bytes
FROM maplefile.files_by_user
WHERE user_id = ?`
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
var totalSize int64
var fileID, collectionID gocql.UUID
var state string
var fileSize, thumbnailSize int64
for iter.Scan(&fileID, &collectionID, &state, &fileSize, &thumbnailSize) {
// Only include files from accessible collections
if !accessibleCollections[collectionID] {
continue
}
// Only include active files in size calculation
if state != dom_file.FileStateActive {
continue
}
// Add both file and thumbnail sizes
totalSize += fileSize + thumbnailSize
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to calculate total storage size by user",
zap.String("user_id", userID.String()),
zap.Int("accessible_collections_count", len(accessibleCollectionIDs)),
zap.Error(err))
return 0, fmt.Errorf("failed to calculate total storage size by user: %w", err)
}
impl.Logger.Debug("calculated total storage size by user successfully",
zap.String("user_id", userID.String()),
zap.Int("accessible_collections_count", len(accessibleCollectionIDs)),
zap.Int64("total_size_bytes", totalSize))
return totalSize, nil
}
// GetTotalStorageSizeByCollection calculates total storage size for all active files in a specific collection
func (impl *fileMetadataRepositoryImpl) GetTotalStorageSizeByCollection(ctx context.Context, collectionID gocql.UUID) (int64, error) {
// Query files in the collection using the collection table
query := `SELECT id, state, encrypted_file_size_in_bytes, encrypted_thumbnail_size_in_bytes
FROM maplefile.files_by_collection
WHERE collection_id = ?`
iter := impl.Session.Query(query, collectionID).WithContext(ctx).Iter()
var totalSize int64
var fileID gocql.UUID
var state string
var fileSize, thumbnailSize int64
for iter.Scan(&fileID, &state, &fileSize, &thumbnailSize) {
// Only include active files in size calculation
if state != dom_file.FileStateActive {
continue
}
// Add both file and thumbnail sizes
totalSize += fileSize + thumbnailSize
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to calculate total storage size by collection",
zap.String("collection_id", collectionID.String()),
zap.Error(err))
return 0, fmt.Errorf("failed to calculate total storage size by collection: %w", err)
}
impl.Logger.Debug("calculated total storage size by collection successfully",
zap.String("collection_id", collectionID.String()),
zap.Int64("total_size_bytes", totalSize))
return totalSize, nil
}
// GetStorageSizeBreakdownByUser provides detailed breakdown of storage usage
// Returns owned size, shared size, and detailed collection breakdown
func (impl *fileMetadataRepositoryImpl) GetStorageSizeBreakdownByUser(ctx context.Context, userID gocql.UUID, ownedCollectionIDs, sharedCollectionIDs []gocql.UUID) (ownedSize, sharedSize int64, collectionBreakdown map[gocql.UUID]int64, err error) {
collectionBreakdown = make(map[gocql.UUID]int64)
// Calculate owned files storage size
if len(ownedCollectionIDs) > 0 {
ownedSize, err = impl.GetTotalStorageSizeByUser(ctx, userID, ownedCollectionIDs)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to calculate owned storage size: %w", err)
}
// Get breakdown by owned collections
for _, collectionID := range ownedCollectionIDs {
size, sizeErr := impl.GetTotalStorageSizeByCollection(ctx, collectionID)
if sizeErr != nil {
impl.Logger.Warn("failed to get storage size for owned collection",
zap.String("collection_id", collectionID.String()),
zap.Error(sizeErr))
continue
}
collectionBreakdown[collectionID] = size
}
}
// Calculate shared files storage size
if len(sharedCollectionIDs) > 0 {
sharedSize, err = impl.GetTotalStorageSizeByUser(ctx, userID, sharedCollectionIDs)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to calculate shared storage size: %w", err)
}
// Get breakdown by shared collections
for _, collectionID := range sharedCollectionIDs {
size, sizeErr := impl.GetTotalStorageSizeByCollection(ctx, collectionID)
if sizeErr != nil {
impl.Logger.Warn("failed to get storage size for shared collection",
zap.String("collection_id", collectionID.String()),
zap.Error(sizeErr))
continue
}
// Note: For shared collections, this shows the total size of the collection,
// not just the user's contribution to it
collectionBreakdown[collectionID] = size
}
}
impl.Logger.Debug("calculated storage size breakdown successfully",
zap.String("user_id", userID.String()),
zap.Int64("owned_size_bytes", ownedSize),
zap.Int64("shared_size_bytes", sharedSize),
zap.Int("owned_collections_count", len(ownedCollectionIDs)),
zap.Int("shared_collections_count", len(sharedCollectionIDs)))
return ownedSize, sharedSize, collectionBreakdown, nil
}

View file

@ -0,0 +1,247 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/filemetadata/update.go
package filemetadata
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
func (impl *fileMetadataRepositoryImpl) Update(file *dom_file.File) error {
if file == nil {
return fmt.Errorf("file cannot be nil")
}
if !impl.isValidUUID(file.ID) {
return fmt.Errorf("file ID is required")
}
// Get existing file to compare changes
existing, err := impl.Get(file.ID)
if err != nil {
return fmt.Errorf("failed to get existing file: %w", err)
}
if existing == nil {
return fmt.Errorf("file not found")
}
// Update modified timestamp
file.ModifiedAt = time.Now()
// Serialize encrypted file key
encryptedKeyJSON, err := impl.serializeEncryptedFileKey(file.EncryptedFileKey)
if err != nil {
return fmt.Errorf("failed to serialize encrypted file key: %w", err)
}
// Serialize tags
tagsJSON, err := impl.serializeTags(file.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 maplefile.files_by_id SET
collection_id = ?, owner_id = ?, encrypted_metadata = ?, encrypted_file_key = ?,
encryption_version = ?, encrypted_hash = ?, encrypted_file_object_key = ?,
encrypted_file_size_in_bytes = ?, encrypted_thumbnail_object_key = ?,
encrypted_thumbnail_size_in_bytes = ?, tags = ?, created_at = ?, created_by_user_id = ?,
modified_at = ?, modified_by_user_id = ?, version = ?, state = ?,
tombstone_version = ?, tombstone_expiry = ?
WHERE id = ?`,
file.CollectionID, file.OwnerID, file.EncryptedMetadata, encryptedKeyJSON,
file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, tagsJSON, file.CreatedAt, file.CreatedByUserID,
file.ModifiedAt, file.ModifiedByUserID, file.Version, file.State,
file.TombstoneVersion, file.TombstoneExpiry, file.ID)
// 2. Update collection table - delete old entry and insert new one
if existing.CollectionID != file.CollectionID || existing.ModifiedAt != file.ModifiedAt {
batch.Query(`DELETE FROM maplefile.files_by_collection
WHERE collection_id = ? AND modified_at = ? AND id = ?`,
existing.CollectionID, existing.ModifiedAt, file.ID)
batch.Query(`INSERT INTO maplefile.files_by_collection
(collection_id, modified_at, id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.CollectionID, file.ModifiedAt, file.ID, file.OwnerID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
}
// 3. Update owner table - delete old entry and insert new one
if existing.OwnerID != file.OwnerID || existing.ModifiedAt != file.ModifiedAt {
batch.Query(`DELETE FROM maplefile.files_by_owner
WHERE owner_id = ? AND modified_at = ? AND id = ?`,
existing.OwnerID, existing.ModifiedAt, file.ID)
batch.Query(`INSERT INTO maplefile.files_by_owner
(owner_id, modified_at, id, collection_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key, encrypted_file_size_in_bytes,
encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.OwnerID, file.ModifiedAt, file.ID, file.CollectionID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash, file.EncryptedFileObjectKey,
file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
}
// 4. Update created_by table - only if creator changed (rare) or created date changed
if existing.CreatedByUserID != file.CreatedByUserID || existing.CreatedAt != file.CreatedAt {
batch.Query(`DELETE FROM maplefile.files_by_creator
WHERE created_by_user_id = ? AND created_at = ? AND id = ?`,
existing.CreatedByUserID, existing.CreatedAt, file.ID)
batch.Query(`INSERT INTO maplefile.files_by_creator
(created_by_user_id, created_at, id, collection_id, owner_id, encrypted_metadata,
encrypted_file_key, encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
modified_at, modified_by_user_id, version, state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.CreatedByUserID, file.CreatedAt, file.ID, file.CollectionID, file.OwnerID,
file.EncryptedMetadata, encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, file.ModifiedAt, file.ModifiedByUserID, file.Version,
file.State, file.TombstoneVersion, file.TombstoneExpiry)
}
// 5. Update user sync table - delete old entry and insert new one for owner
batch.Query(`DELETE FROM maplefile.files_by_user
WHERE user_id = ? AND modified_at = ? AND id = ?`,
existing.OwnerID, existing.ModifiedAt, file.ID)
batch.Query(`INSERT INTO maplefile.files_by_user
(user_id, modified_at, id, collection_id, owner_id, encrypted_metadata,
encrypted_file_key, encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key, encrypted_thumbnail_size_in_bytes,
tags, created_at, created_by_user_id, modified_by_user_id, version,
state, tombstone_version, tombstone_expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file.OwnerID, file.ModifiedAt, file.ID, file.CollectionID, file.OwnerID,
file.EncryptedMetadata, encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes, file.EncryptedThumbnailObjectKey,
file.EncryptedThumbnailSizeInBytes, tagsJSON, file.CreatedAt, file.CreatedByUserID,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion, file.TombstoneExpiry)
// 6. Update denormalized files_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 file.Tags {
newTagsMap[tag.ID] = true
}
// Delete entries for removed tags
for tagID := range oldTagsMap {
if !newTagsMap[tagID] {
impl.Logger.Debug("removing file from tag denormalized table",
zap.String("file_id", file.ID.String()),
zap.String("tag_id", tagID.String()))
batch.Query(`DELETE FROM maplefile.files_by_tag_id
WHERE tag_id = ? AND file_id = ?`,
tagID, file.ID)
}
}
// Insert/Update entries for current tags
for _, tag := range file.Tags {
impl.Logger.Debug("updating file in tag denormalized table",
zap.String("file_id", file.ID.String()),
zap.String("tag_id", tag.ID.String()))
batch.Query(`INSERT INTO maplefile.files_by_tag_id
(tag_id, file_id, collection_id, owner_id, encrypted_metadata, encrypted_file_key,
encryption_version, encrypted_hash, encrypted_file_object_key,
encrypted_file_size_in_bytes, encrypted_thumbnail_object_key,
encrypted_thumbnail_size_in_bytes, tag_ids, 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, file.ID, file.CollectionID, file.OwnerID, file.EncryptedMetadata,
encryptedKeyJSON, file.EncryptionVersion, file.EncryptedHash,
file.EncryptedFileObjectKey, file.EncryptedFileSizeInBytes,
file.EncryptedThumbnailObjectKey, file.EncryptedThumbnailSizeInBytes,
tagsJSON, file.CreatedAt, file.CreatedByUserID, file.ModifiedAt,
file.ModifiedByUserID, file.Version, file.State, file.TombstoneVersion,
file.TombstoneExpiry,
nil, nil, nil) // IP tracking fields not yet in domain model
}
// Execute batch
if err := impl.Session.ExecuteBatch(batch); err != nil {
impl.Logger.Error("failed to update file",
zap.String("file_id", file.ID.String()),
zap.Error(err))
return fmt.Errorf("failed to update file: %w", err)
}
// Handle file count updates based on state changes
wasActive := existing.State == dom_file.FileStateActive
isActive := file.State == dom_file.FileStateActive
// Handle collection change for active files
if existing.CollectionID != file.CollectionID && wasActive && isActive {
// File moved from one collection to another while remaining active
// Decrement old collection count
if err := impl.CollectionRepo.DecrementFileCount(context.Background(), existing.CollectionID); err != nil {
impl.Logger.Error("failed to decrement old collection file count",
zap.String("file_id", file.ID.String()),
zap.String("collection_id", existing.CollectionID.String()),
zap.Error(err))
// Don't fail the entire operation if count update fails
}
// Increment new collection count
if err := impl.CollectionRepo.IncrementFileCount(context.Background(), file.CollectionID); err != nil {
impl.Logger.Error("failed to increment new collection file count",
zap.String("file_id", file.ID.String()),
zap.String("collection_id", file.CollectionID.String()),
zap.Error(err))
// Don't fail the entire operation if count update fails
}
} else if wasActive && !isActive {
// File transitioned from active to non-active (e.g., deleted)
if err := impl.CollectionRepo.DecrementFileCount(context.Background(), existing.CollectionID); err != nil {
impl.Logger.Error("failed to decrement collection file count",
zap.String("file_id", file.ID.String()),
zap.String("collection_id", existing.CollectionID.String()),
zap.Error(err))
// Don't fail the entire operation if count update fails
}
} else if !wasActive && isActive {
// File transitioned from non-active to active (e.g., restored)
if err := impl.CollectionRepo.IncrementFileCount(context.Background(), file.CollectionID); err != nil {
impl.Logger.Error("failed to increment collection file count",
zap.String("file_id", file.ID.String()),
zap.String("collection_id", file.CollectionID.String()),
zap.Error(err))
// Don't fail the entire operation if count update fails
}
}
impl.Logger.Info("file updated successfully",
zap.String("file_id", file.ID.String()))
return nil
}

View file

@ -0,0 +1,24 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/delete.go
package fileobjectstorage
import (
"context"
"go.uber.org/zap"
)
// DeleteEncryptedData removes encrypted file data from S3
func (impl *fileObjectStorageRepositoryImpl) DeleteEncryptedData(storagePath string) error {
ctx := context.Background()
// Delete the encrypted data
err := impl.Storage.DeleteByKeys(ctx, []string{storagePath})
if err != nil {
impl.Logger.Error("Failed to delete encrypted data",
zap.String("storagePath", storagePath),
zap.Error(err))
return err
}
return nil
}

View file

@ -0,0 +1,35 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/get_encrypted_data.go
package fileobjectstorage
import (
"context"
"io"
"go.uber.org/zap"
)
// GetEncryptedData retrieves encrypted file data from S3
func (impl *fileObjectStorageRepositoryImpl) GetEncryptedData(storagePath string) ([]byte, error) {
ctx := context.Background()
// Get the encrypted data
reader, err := impl.Storage.GetBinaryData(ctx, storagePath)
if err != nil {
impl.Logger.Error("Failed to get encrypted data",
zap.String("storagePath", storagePath),
zap.Error(err))
return nil, err
}
defer reader.Close()
// Read all data into memory
data, err := io.ReadAll(reader)
if err != nil {
impl.Logger.Error("Failed to read encrypted data",
zap.String("storagePath", storagePath),
zap.Error(err))
return nil, err
}
return data, nil
}

View file

@ -0,0 +1,28 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/get_object_size.go
package fileobjectstorage
import (
"context"
"go.uber.org/zap"
)
// GetObjectSize returns the size in bytes of an object at the given storage path
func (impl *fileObjectStorageRepositoryImpl) GetObjectSize(storagePath string) (int64, error) {
ctx := context.Background()
// Get object size from storage
size, err := impl.Storage.GetObjectSize(ctx, storagePath)
if err != nil {
impl.Logger.Error("Failed to get object size",
zap.String("storagePath", storagePath),
zap.Error(err))
return 0, err
}
impl.Logger.Debug("Retrieved object size",
zap.String("storagePath", storagePath),
zap.Int64("size", size))
return size, nil
}

View file

@ -0,0 +1,25 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/impl.go
package fileobjectstorage
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
s3storage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/storage/object/s3"
)
type fileObjectStorageRepositoryImpl struct {
Config *config.Configuration
Logger *zap.Logger
Storage s3storage.S3ObjectStorage
}
func NewRepository(cfg *config.Configuration, logger *zap.Logger, s3 s3storage.S3ObjectStorage) dom_file.FileObjectStorageRepository {
logger = logger.Named("FileObjectStorageRepository")
return &fileObjectStorageRepositoryImpl{
Config: cfg,
Logger: logger.With(zap.String("repository", "file_storage")),
Storage: s3,
}
}

View file

@ -0,0 +1,52 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/presigned_download_url.go
package fileobjectstorage
import (
"context"
"net/url"
"time"
"go.uber.org/zap"
)
// GeneratePresignedDownloadURL creates a time-limited URL that allows direct download
// of the file data located at the given storage path, with proper content disposition headers.
func (impl *fileObjectStorageRepositoryImpl) GeneratePresignedDownloadURL(storagePath string, duration time.Duration) (string, error) {
ctx := context.Background()
// Generate presigned download URL with content disposition
presignedURL, err := impl.Storage.GetDownloadablePresignedURL(ctx, storagePath, duration)
if err != nil {
impl.Logger.Error("Failed to generate presigned download URL",
zap.String("storagePath", storagePath),
zap.Duration("duration", duration),
zap.Error(err))
return "", err
}
// Replace the hostname in the presigned URL with the public endpoint if configured
if impl.Config.S3.PublicEndpoint != "" && impl.Config.S3.PublicEndpoint != impl.Config.S3.Endpoint {
parsedURL, err := url.Parse(presignedURL)
if err == nil {
// Parse the public endpoint to get the host
publicEndpoint, err := url.Parse(impl.Config.S3.PublicEndpoint)
if err == nil {
// Replace the host in the presigned URL
parsedURL.Scheme = publicEndpoint.Scheme
parsedURL.Host = publicEndpoint.Host
presignedURL = parsedURL.String()
impl.Logger.Debug("Replaced presigned URL hostname with public endpoint",
zap.String("original_endpoint", impl.Config.S3.Endpoint),
zap.String("public_endpoint", impl.Config.S3.PublicEndpoint))
}
}
}
impl.Logger.Debug("Generated presigned download URL",
zap.String("storagePath", storagePath),
zap.Duration("duration", duration),
zap.String("url", presignedURL))
return presignedURL, nil
}

View file

@ -0,0 +1,31 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/presigned_upload_url.go
package fileobjectstorage
import (
"context"
"time"
"go.uber.org/zap"
)
// GeneratePresignedUploadURL creates a temporary, time-limited URL that allows clients to upload
// encrypted file data directly to the storage system at the specified storage path.
func (impl *fileObjectStorageRepositoryImpl) GeneratePresignedUploadURL(storagePath string, duration time.Duration) (string, error) {
ctx := context.Background()
// Generate presigned upload URL
url, err := impl.Storage.GeneratePresignedUploadURL(ctx, storagePath, duration)
if err != nil {
impl.Logger.Error("Failed to generate presigned upload URL",
zap.String("storagePath", storagePath),
zap.Duration("duration", duration),
zap.Error(err))
return "", err
}
impl.Logger.Debug("Generated presigned upload URL",
zap.String("storagePath", storagePath),
zap.Duration("duration", duration))
return url, nil
}

View file

@ -0,0 +1,14 @@
package fileobjectstorage
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
s3storage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/storage/object/s3"
)
// ProvideRepository provides a file object storage repository for Wire DI
func ProvideRepository(cfg *config.Config, logger *zap.Logger, s3 s3storage.S3ObjectStorage) dom_file.FileObjectStorageRepository {
return NewRepository(cfg, logger, s3)
}

View file

@ -0,0 +1,29 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/upload.go
package fileobjectstorage
import (
"context"
"fmt"
"go.uber.org/zap"
)
// StoreEncryptedData uploads encrypted file data to S3 and returns the storage path
func (impl *fileObjectStorageRepositoryImpl) StoreEncryptedData(ownerID string, fileID string, encryptedData []byte) (string, error) {
ctx := context.Background()
// Generate a storage path using a deterministic pattern
storagePath := fmt.Sprintf("users/%s/files/%s", ownerID, fileID)
// Always store encrypted data as private
err := impl.Storage.UploadContentWithVisibility(ctx, storagePath, encryptedData, false)
if err != nil {
impl.Logger.Error("Failed to store encrypted data",
zap.String("fileID", fileID),
zap.String("ownerID", ownerID),
zap.Error(err))
return "", err
}
return storagePath, nil
}

View file

@ -0,0 +1,28 @@
// monorepo/cloud/backend/internal/maplefile/repo/fileobjectstorage/verify_object_exists.go
package fileobjectstorage
import (
"context"
"go.uber.org/zap"
)
// VerifyObjectExists checks if an object exists at the given storage path.
func (impl *fileObjectStorageRepositoryImpl) VerifyObjectExists(storagePath string) (bool, error) {
ctx := context.Background()
// Check if object exists in storage
exists, err := impl.Storage.ObjectExists(ctx, storagePath)
if err != nil {
impl.Logger.Error("Failed to verify if object exists",
zap.String("storagePath", storagePath),
zap.Error(err))
return false, err
}
impl.Logger.Debug("Verified object existence",
zap.String("storagePath", storagePath),
zap.Bool("exists", exists))
return exists, nil
}

View file

@ -0,0 +1,40 @@
package inviteemailratelimit
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// GetDailyEmailCount returns the number of invitation emails sent by a user on the given date.
// Returns 0 if no record exists (user hasn't sent any invites today).
func (r *repositoryImpl) GetDailyEmailCount(ctx context.Context, userID gocql.UUID, date time.Time) (int, error) {
r.logger.Debug("Getting daily email count",
zap.String("user_id", userID.String()),
zap.Time("date", date))
// Normalize date to midnight UTC
dateOnly := date.UTC().Truncate(24 * time.Hour)
var count int64
query := r.session.Query(`
SELECT emails_sent_today
FROM invite_email_rate_limits_by_user_id_and_date
WHERE user_id = ? AND date = ?
`, userID, dateOnly).WithContext(ctx)
if err := query.Scan(&count); err != nil {
if err == gocql.ErrNotFound {
// No record means no emails sent today
return 0, nil
}
r.logger.Error("Failed to get daily email count",
zap.String("user_id", userID.String()),
zap.Error(err))
return 0, err
}
return int(count), nil
}

View file

@ -0,0 +1,36 @@
// Package inviteemailratelimit provides rate limiting for invitation emails
// using Cassandra counter tables.
package inviteemailratelimit
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
)
// Repository defines the interface for invite email rate limiting
type Repository interface {
// GetDailyEmailCount returns the number of invitation emails sent by a user today
GetDailyEmailCount(ctx context.Context, userID gocql.UUID, date time.Time) (int, error)
// IncrementDailyEmailCount increments the counter for emails sent today
IncrementDailyEmailCount(ctx context.Context, userID gocql.UUID, date time.Time) error
}
type repositoryImpl struct {
logger *zap.Logger
session *gocql.Session
}
// NewRepository creates a new invite email rate limit repository
func NewRepository(appCfg *config.Configuration, session *gocql.Session, logger *zap.Logger) Repository {
logger = logger.Named("InviteEmailRateLimitRepository")
return &repositoryImpl{
logger: logger,
session: session,
}
}

View file

@ -0,0 +1,42 @@
package inviteemailratelimit
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// IncrementDailyEmailCount increments the counter for emails sent by a user on the given date.
// Uses Cassandra COUNTER type for atomic increment operations.
// TTL of 2 days (172800 seconds) is applied at the UPDATE level since counter tables
// do not support default_time_to_live in Cassandra.
func (r *repositoryImpl) IncrementDailyEmailCount(ctx context.Context, userID gocql.UUID, date time.Time) error {
r.logger.Debug("Incrementing daily email count",
zap.String("user_id", userID.String()),
zap.Time("date", date))
// Normalize date to midnight UTC
dateOnly := date.UTC().Truncate(24 * time.Hour)
// TTL of 172800 seconds = 2 days for automatic cleanup
query := r.session.Query(`
UPDATE invite_email_rate_limits_by_user_id_and_date
USING TTL 172800
SET emails_sent_today = emails_sent_today + 1
WHERE user_id = ? AND date = ?
`, userID, dateOnly).WithContext(ctx)
if err := query.Exec(); err != nil {
r.logger.Error("Failed to increment daily email count",
zap.String("user_id", userID.String()),
zap.Error(err))
return err
}
r.logger.Debug("Successfully incremented daily email count",
zap.String("user_id", userID.String()))
return nil
}

View file

@ -0,0 +1,13 @@
package inviteemailratelimit
import (
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
)
// ProvideRepository provides an invite email rate limit repository for Wire DI
func ProvideRepository(cfg *config.Config, session *gocql.Session, logger *zap.Logger) Repository {
return NewRepository(cfg, session, logger)
}

View file

@ -0,0 +1,138 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storagedailyusage/create.go
package storagedailyusage
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/storagedailyusage"
)
func (impl *storageDailyUsageRepositoryImpl) Create(ctx context.Context, usage *storagedailyusage.StorageDailyUsage) error {
if usage == nil {
return fmt.Errorf("storage daily usage cannot be nil")
}
// Ensure usage day is truncated to date only
usage.UsageDay = usage.UsageDay.Truncate(24 * time.Hour)
query := `INSERT INTO storage_daily_usage_by_user_id_with_asc_usage_day
(user_id, usage_day, total_bytes, total_add_bytes, total_remove_bytes)
VALUES (?, ?, ?, ?, ?)`
err := impl.Session.Query(query,
usage.UserID,
usage.UsageDay,
usage.TotalBytes,
usage.TotalAddBytes,
usage.TotalRemoveBytes,
).WithContext(ctx).Exec()
if err != nil {
impl.Logger.Error("failed to create storage daily usage",
zap.String("user_id", usage.UserID.String()),
zap.Time("usage_day", usage.UsageDay),
zap.Error(err))
return fmt.Errorf("failed to create storage daily usage: %w", err)
}
return nil
}
func (impl *storageDailyUsageRepositoryImpl) CreateMany(ctx context.Context, usages []*storagedailyusage.StorageDailyUsage) error {
if len(usages) == 0 {
return nil
}
batch := impl.Session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
for _, usage := range usages {
if usage == nil {
continue
}
// Ensure usage day is truncated to date only
usage.UsageDay = usage.UsageDay.Truncate(24 * time.Hour)
batch.Query(`INSERT INTO storage_daily_usage_by_user_id_with_asc_usage_day
(user_id, usage_day, total_bytes, total_add_bytes, total_remove_bytes)
VALUES (?, ?, ?, ?, ?)`,
usage.UserID,
usage.UsageDay,
usage.TotalBytes,
usage.TotalAddBytes,
usage.TotalRemoveBytes,
)
}
err := impl.Session.ExecuteBatch(batch)
if err != nil {
impl.Logger.Error("failed to create multiple storage daily usages", zap.Error(err))
return fmt.Errorf("failed to create multiple storage daily usages: %w", err)
}
return nil
}
func (impl *storageDailyUsageRepositoryImpl) IncrementUsage(ctx context.Context, userID gocql.UUID, usageDay time.Time, totalBytes, addBytes, removeBytes int64) error {
// Ensure usage day is truncated to date only
usageDay = usageDay.Truncate(24 * time.Hour)
// First, get the current values
existing, err := impl.GetByUserAndDay(ctx, userID, usageDay)
if err != nil {
impl.Logger.Error("failed to get existing usage for increment",
zap.Error(err),
zap.String("user_id", userID.String()),
zap.Time("usage_day", usageDay))
return fmt.Errorf("failed to get existing usage: %w", err)
}
// Calculate new values
var newTotalBytes, newAddBytes, newRemoveBytes int64
if existing != nil {
// Add to existing values
newTotalBytes = existing.TotalBytes + totalBytes
newAddBytes = existing.TotalAddBytes + addBytes
newRemoveBytes = existing.TotalRemoveBytes + removeBytes
} else {
// First record for this day
newTotalBytes = totalBytes
newAddBytes = addBytes
newRemoveBytes = removeBytes
}
// Insert/Update with the new values
query := `
INSERT INTO storage_daily_usage_by_user_id_with_asc_usage_day
(user_id, usage_day, total_bytes, total_add_bytes, total_remove_bytes)
VALUES (?, ?, ?, ?, ?)`
if err := impl.Session.Query(query,
userID,
usageDay,
newTotalBytes,
newAddBytes,
newRemoveBytes,
).WithContext(ctx).Exec(); err != nil {
impl.Logger.Error("failed to increment storage daily usage",
zap.Error(err),
zap.String("user_id", userID.String()),
zap.Time("usage_day", usageDay))
return fmt.Errorf("failed to increment storage daily usage: %w", err)
}
impl.Logger.Debug("storage daily usage incremented",
zap.String("user_id", userID.String()),
zap.Time("usage_day", usageDay),
zap.Int64("total_bytes_delta", totalBytes),
zap.Int64("add_bytes_delta", addBytes),
zap.Int64("remove_bytes_delta", removeBytes),
zap.Int64("new_total_bytes", newTotalBytes),
zap.Int64("new_add_bytes", newAddBytes),
zap.Int64("new_remove_bytes", newRemoveBytes))
return nil
}

View file

@ -0,0 +1,47 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storagedailyusage/delete.go
package storagedailyusage
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
func (impl *storageDailyUsageRepositoryImpl) DeleteByUserAndDay(ctx context.Context, userID gocql.UUID, usageDay time.Time) error {
// Ensure usage day is truncated to date only
usageDay = usageDay.Truncate(24 * time.Hour)
query := `DELETE FROM maplefile.storage_daily_usage_by_user_id_with_asc_usage_day
WHERE user_id = ? AND usage_day = ?`
err := impl.Session.Query(query, userID, usageDay).WithContext(ctx).Exec()
if err != nil {
impl.Logger.Error("failed to delete storage daily usage", zap.Error(err))
return fmt.Errorf("failed to delete storage daily usage: %w", err)
}
return nil
}
// DeleteByUserID deletes all storage daily usage records for a user (all days)
// Used for GDPR right-to-be-forgotten implementation
func (impl *storageDailyUsageRepositoryImpl) DeleteByUserID(ctx context.Context, userID gocql.UUID) error {
query := `DELETE FROM maplefile.storage_daily_usage_by_user_id_with_asc_usage_day
WHERE user_id = ?`
err := impl.Session.Query(query, userID).WithContext(ctx).Exec()
if err != nil {
impl.Logger.Error("failed to delete all storage daily usage for user",
zap.String("user_id", userID.String()),
zap.Error(err))
return fmt.Errorf("failed to delete all storage daily usage for user %s: %w", userID.String(), err)
}
impl.Logger.Info("✅ Deleted all storage daily usage records for user",
zap.String("user_id", userID.String()))
return nil
}

View file

@ -0,0 +1,221 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storagedailyusage/get.go
package storagedailyusage
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/storagedailyusage"
)
func (impl *storageDailyUsageRepositoryImpl) GetByUserAndDay(ctx context.Context, userID gocql.UUID, usageDay time.Time) (*storagedailyusage.StorageDailyUsage, error) {
// Ensure usage day is truncated to date only
usageDay = usageDay.Truncate(24 * time.Hour)
var (
resultUserID gocql.UUID
resultUsageDay time.Time
totalBytes int64
totalAddBytes int64
totalRemoveBytes int64
)
query := `SELECT user_id, usage_day, total_bytes, total_add_bytes, total_remove_bytes
FROM maplefile.storage_daily_usage_by_user_id_with_asc_usage_day
WHERE user_id = ? AND usage_day = ?`
err := impl.Session.Query(query, userID, usageDay).WithContext(ctx).Scan(
&resultUserID, &resultUsageDay, &totalBytes, &totalAddBytes, &totalRemoveBytes)
if err == gocql.ErrNotFound {
return nil, nil
}
if err != nil {
impl.Logger.Error("failed to get storage daily usage", zap.Error(err))
return nil, fmt.Errorf("failed to get storage daily usage: %w", err)
}
usage := &storagedailyusage.StorageDailyUsage{
UserID: resultUserID,
UsageDay: resultUsageDay,
TotalBytes: totalBytes,
TotalAddBytes: totalAddBytes,
TotalRemoveBytes: totalRemoveBytes,
}
return usage, nil
}
func (impl *storageDailyUsageRepositoryImpl) GetByUserDateRange(ctx context.Context, userID gocql.UUID, startDay, endDay time.Time) ([]*storagedailyusage.StorageDailyUsage, error) {
// Ensure dates are truncated to date only
startDay = startDay.Truncate(24 * time.Hour)
endDay = endDay.Truncate(24 * time.Hour)
query := `SELECT user_id, usage_day, total_bytes, total_add_bytes, total_remove_bytes
FROM maplefile.storage_daily_usage_by_user_id_with_asc_usage_day
WHERE user_id = ? AND usage_day >= ? AND usage_day <= ?`
iter := impl.Session.Query(query, userID, startDay, endDay).WithContext(ctx).Iter()
var usages []*storagedailyusage.StorageDailyUsage
var (
resultUserID gocql.UUID
resultUsageDay time.Time
totalBytes int64
totalAddBytes int64
totalRemoveBytes int64
)
for iter.Scan(&resultUserID, &resultUsageDay, &totalBytes, &totalAddBytes, &totalRemoveBytes) {
usage := &storagedailyusage.StorageDailyUsage{
UserID: resultUserID,
UsageDay: resultUsageDay,
TotalBytes: totalBytes,
TotalAddBytes: totalAddBytes,
TotalRemoveBytes: totalRemoveBytes,
}
usages = append(usages, usage)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to get storage daily usage by date range", zap.Error(err))
return nil, fmt.Errorf("failed to get storage daily usage: %w", err)
}
return usages, nil
}
// GetLast7DaysTrend retrieves the last 7 days of storage usage and calculates trends
func (impl *storageDailyUsageRepositoryImpl) GetLast7DaysTrend(ctx context.Context, userID gocql.UUID) (*storagedailyusage.StorageUsageTrend, error) {
endDay := time.Now().Truncate(24 * time.Hour)
startDay := endDay.Add(-6 * 24 * time.Hour) // 7 days including today
usages, err := impl.GetByUserDateRange(ctx, userID, startDay, endDay)
if err != nil {
return nil, err
}
return impl.calculateTrend(userID, startDay, endDay, usages), nil
}
// GetMonthlyTrend retrieves usage trend for a specific month
func (impl *storageDailyUsageRepositoryImpl) GetMonthlyTrend(ctx context.Context, userID gocql.UUID, year int, month time.Month) (*storagedailyusage.StorageUsageTrend, error) {
startDay := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
endDay := startDay.AddDate(0, 1, -1) // Last day of the month
usages, err := impl.GetByUserDateRange(ctx, userID, startDay, endDay)
if err != nil {
return nil, err
}
return impl.calculateTrend(userID, startDay, endDay, usages), nil
}
// GetYearlyTrend retrieves usage trend for a specific year
func (impl *storageDailyUsageRepositoryImpl) GetYearlyTrend(ctx context.Context, userID gocql.UUID, year int) (*storagedailyusage.StorageUsageTrend, error) {
startDay := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
endDay := time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC)
usages, err := impl.GetByUserDateRange(ctx, userID, startDay, endDay)
if err != nil {
return nil, err
}
return impl.calculateTrend(userID, startDay, endDay, usages), nil
}
// GetCurrentMonthUsage gets the current month's usage summary
func (impl *storageDailyUsageRepositoryImpl) GetCurrentMonthUsage(ctx context.Context, userID gocql.UUID) (*storagedailyusage.StorageUsageSummary, error) {
now := time.Now()
startDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
endDay := now.Truncate(24 * time.Hour)
usages, err := impl.GetByUserDateRange(ctx, userID, startDay, endDay)
if err != nil {
return nil, err
}
return impl.calculateSummary(userID, "month", startDay, endDay, usages), nil
}
// GetCurrentYearUsage gets the current year's usage summary
func (impl *storageDailyUsageRepositoryImpl) GetCurrentYearUsage(ctx context.Context, userID gocql.UUID) (*storagedailyusage.StorageUsageSummary, error) {
now := time.Now()
startDay := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
endDay := now.Truncate(24 * time.Hour)
usages, err := impl.GetByUserDateRange(ctx, userID, startDay, endDay)
if err != nil {
return nil, err
}
return impl.calculateSummary(userID, "year", startDay, endDay, usages), nil
}
// Helper methods
func (impl *storageDailyUsageRepositoryImpl) calculateTrend(userID gocql.UUID, startDay, endDay time.Time, usages []*storagedailyusage.StorageDailyUsage) *storagedailyusage.StorageUsageTrend {
trend := &storagedailyusage.StorageUsageTrend{
UserID: userID,
StartDate: startDay,
EndDate: endDay,
DailyUsages: usages,
}
if len(usages) == 0 {
return trend
}
var peakDay time.Time
var peakBytes int64
for _, usage := range usages {
trend.TotalAdded += usage.TotalAddBytes
trend.TotalRemoved += usage.TotalRemoveBytes
if usage.TotalBytes > peakBytes {
peakBytes = usage.TotalBytes
peakDay = usage.UsageDay
}
}
trend.NetChange = trend.TotalAdded - trend.TotalRemoved
if len(usages) > 0 {
trend.AverageDailyAdd = trend.TotalAdded / int64(len(usages))
trend.PeakUsageDay = &peakDay
trend.PeakUsageBytes = peakBytes
}
return trend
}
func (impl *storageDailyUsageRepositoryImpl) calculateSummary(userID gocql.UUID, period string, startDay, endDay time.Time, usages []*storagedailyusage.StorageDailyUsage) *storagedailyusage.StorageUsageSummary {
summary := &storagedailyusage.StorageUsageSummary{
UserID: userID,
Period: period,
StartDate: startDay,
EndDate: endDay,
DaysWithData: len(usages),
}
if len(usages) == 0 {
return summary
}
// Get the most recent usage as current
summary.CurrentUsage = usages[len(usages)-1].TotalBytes
for _, usage := range usages {
summary.TotalAdded += usage.TotalAddBytes
summary.TotalRemoved += usage.TotalRemoveBytes
}
summary.NetChange = summary.TotalAdded - summary.TotalRemoved
return summary
}

View file

@ -0,0 +1,24 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storagedailyusage/impl.go
package storagedailyusage
import (
"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/storagedailyusage"
)
type storageDailyUsageRepositoryImpl struct {
Logger *zap.Logger
Session *gocql.Session
}
func NewRepository(appCfg *config.Configuration, session *gocql.Session, loggerp *zap.Logger) storagedailyusage.StorageDailyUsageRepository {
loggerp = loggerp.Named("StorageDailyUsageRepository")
return &storageDailyUsageRepositoryImpl{
Logger: loggerp,
Session: session,
}
}

View file

@ -0,0 +1,14 @@
package storagedailyusage
import (
"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/storagedailyusage"
)
// ProvideRepository provides a storage daily usage repository for Wire DI
func ProvideRepository(cfg *config.Config, session *gocql.Session, logger *zap.Logger) storagedailyusage.StorageDailyUsageRepository {
return NewRepository(cfg, session, logger)
}

View file

@ -0,0 +1,41 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storagedailyusage/update.go
package storagedailyusage
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/storagedailyusage"
)
func (impl *storageDailyUsageRepositoryImpl) UpdateOrCreate(ctx context.Context, usage *storagedailyusage.StorageDailyUsage) error {
if usage == nil {
return fmt.Errorf("storage daily usage cannot be nil")
}
// Ensure usage day is truncated to date only
usage.UsageDay = usage.UsageDay.Truncate(24 * time.Hour)
// Use UPSERT (INSERT with no IF NOT EXISTS) to update or create
query := `INSERT INTO storage_daily_usage_by_user_id_with_asc_usage_day
(user_id, usage_day, total_bytes, total_add_bytes, total_remove_bytes)
VALUES (?, ?, ?, ?, ?)`
err := impl.Session.Query(query,
usage.UserID,
usage.UsageDay,
usage.TotalBytes,
usage.TotalAddBytes,
usage.TotalRemoveBytes,
).WithContext(ctx).Exec()
if err != nil {
impl.Logger.Error("failed to upsert storage daily usage", zap.Error(err))
return fmt.Errorf("failed to upsert storage daily usage: %w", err)
}
return nil
}

View file

@ -0,0 +1,88 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storageusageevent/create.go
package storageusageevent
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/storageusageevent"
)
func (impl *storageUsageEventRepositoryImpl) Create(ctx context.Context, event *storageusageevent.StorageUsageEvent) error {
if event == nil {
return fmt.Errorf("storage usage event cannot be nil")
}
// Ensure event day is truncated to date only
event.EventDay = event.EventDay.Truncate(24 * time.Hour)
// Set event time if not provided
if event.EventTime.IsZero() {
event.EventTime = time.Now()
}
query := `INSERT INTO maplefile.storage_usage_events_by_user_id_and_event_day_with_asc_event_time
(user_id, event_day, event_time, file_size, operation)
VALUES (?, ?, ?, ?, ?)`
err := impl.Session.Query(query,
event.UserID,
event.EventDay,
event.EventTime,
event.FileSize,
event.Operation).WithContext(ctx).Exec()
if err != nil {
impl.Logger.Error("failed to create storage usage event",
zap.String("user_id", event.UserID.String()),
zap.String("operation", event.Operation),
zap.Int64("file_size", event.FileSize),
zap.Error(err))
return fmt.Errorf("failed to create storage usage event: %w", err)
}
return nil
}
func (impl *storageUsageEventRepositoryImpl) CreateMany(ctx context.Context, events []*storageusageevent.StorageUsageEvent) error {
if len(events) == 0 {
return nil
}
batch := impl.Session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
for _, event := range events {
if event == nil {
continue
}
// Ensure event day is truncated to date only
event.EventDay = event.EventDay.Truncate(24 * time.Hour)
// Set event time if not provided
if event.EventTime.IsZero() {
event.EventTime = time.Now()
}
batch.Query(`INSERT INTO maplefile.storage_usage_events_by_user_id_and_event_day_with_asc_event_time
(user_id, event_day, event_time, file_size, operation)
VALUES (?, ?, ?, ?, ?)`,
event.UserID,
event.EventDay,
event.EventTime,
event.FileSize,
event.Operation)
}
err := impl.Session.ExecuteBatch(batch)
if err != nil {
impl.Logger.Error("failed to create multiple storage usage events", zap.Error(err))
return fmt.Errorf("failed to create multiple storage usage events: %w", err)
}
return nil
}

View file

@ -0,0 +1,87 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storageusageevent/delete.go
package storageusageevent
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
func (impl *storageUsageEventRepositoryImpl) DeleteByUserAndDay(ctx context.Context, userID gocql.UUID, eventDay time.Time) error {
// Ensure event day is truncated to date only
eventDay = eventDay.Truncate(24 * time.Hour)
query := `DELETE FROM maplefile.storage_usage_events_by_user_id_and_event_day_with_asc_event_time
WHERE user_id = ? AND event_day = ?`
err := impl.Session.Query(query, userID, eventDay).WithContext(ctx).Exec()
if err != nil {
impl.Logger.Error("failed to delete storage usage events by user and day", zap.Error(err))
return fmt.Errorf("failed to delete storage usage events: %w", err)
}
return nil
}
// DeleteByUserID deletes all storage usage events for a user (all days)
// Used for GDPR right-to-be-forgotten implementation
//
// NOTE: Because storage_usage_events table is partitioned by (user_id, event_day),
// we need to query to find all event_day values first, then delete each partition.
// For efficiency, we'll delete up to 2 years of data (should cover most reasonable usage).
func (impl *storageUsageEventRepositoryImpl) DeleteByUserID(ctx context.Context, userID gocql.UUID) error {
// Delete events from the last 2 years (730 days)
// This should cover all reasonable user data retention periods
endDay := time.Now().Truncate(24 * time.Hour)
startDay := endDay.Add(-730 * 24 * time.Hour) // 2 years ago
impl.Logger.Info("Deleting storage usage events for user",
zap.String("user_id", userID.String()),
zap.Time("start_day", startDay),
zap.Time("end_day", endDay))
// Use batch delete for efficiency
batch := impl.Session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
deletedDays := 0
// Delete each day's partition
for day := startDay; !day.After(endDay); day = day.Add(24 * time.Hour) {
query := `DELETE FROM maplefile.storage_usage_events_by_user_id_and_event_day_with_asc_event_time
WHERE user_id = ? AND event_day = ?`
batch.Query(query, userID, day)
deletedDays++
// Execute batch every 100 days to avoid batch size limits
if deletedDays%100 == 0 {
if err := impl.Session.ExecuteBatch(batch); err != nil {
impl.Logger.Error("failed to execute batch delete for storage usage events",
zap.String("user_id", userID.String()),
zap.Int("days_in_batch", 100),
zap.Error(err))
return fmt.Errorf("failed to delete storage usage events for user %s: %w", userID.String(), err)
}
// Create new batch for next set of days
batch = impl.Session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
}
}
// Execute remaining batch
if batch.Size() > 0 {
if err := impl.Session.ExecuteBatch(batch); err != nil {
impl.Logger.Error("failed to execute final batch delete for storage usage events",
zap.String("user_id", userID.String()),
zap.Int("days_in_final_batch", batch.Size()),
zap.Error(err))
return fmt.Errorf("failed to delete storage usage events for user %s: %w", userID.String(), err)
}
}
impl.Logger.Info("✅ Deleted all storage usage events for user",
zap.String("user_id", userID.String()),
zap.Int("total_days_deleted", deletedDays))
return nil
}

View file

@ -0,0 +1,148 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storageusageevent/get.go
package storageusageevent
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/storageusageevent"
)
func (impl *storageUsageEventRepositoryImpl) GetByUserAndDay(ctx context.Context, userID gocql.UUID, eventDay time.Time) ([]*storageusageevent.StorageUsageEvent, error) {
// Ensure event day is truncated to date only
eventDay = eventDay.Truncate(24 * time.Hour)
query := `SELECT user_id, event_day, event_time, file_size, operation
FROM maplefile.storage_usage_events_by_user_id_and_event_day_with_asc_event_time
WHERE user_id = ? AND event_day = ?`
iter := impl.Session.Query(query, userID, eventDay).WithContext(ctx).Iter()
var events []*storageusageevent.StorageUsageEvent
var (
resultUserID gocql.UUID
resultEventDay time.Time
eventTime time.Time
fileSize int64
operation string
)
for iter.Scan(&resultUserID, &resultEventDay, &eventTime, &fileSize, &operation) {
event := &storageusageevent.StorageUsageEvent{
UserID: resultUserID,
EventDay: resultEventDay,
EventTime: eventTime,
FileSize: fileSize,
Operation: operation,
}
events = append(events, event)
}
if err := iter.Close(); err != nil {
impl.Logger.Error("failed to get storage usage events by user and day", zap.Error(err))
return nil, fmt.Errorf("failed to get storage usage events: %w", err)
}
return events, nil
}
func (impl *storageUsageEventRepositoryImpl) GetByUserDateRange(ctx context.Context, userID gocql.UUID, startDay, endDay time.Time) ([]*storageusageevent.StorageUsageEvent, error) {
// Ensure dates are truncated to date only
startDay = startDay.Truncate(24 * time.Hour)
endDay = endDay.Truncate(24 * time.Hour)
// For better performance with large date ranges, we'll query in parallel
var allEvents []*storageusageevent.StorageUsageEvent
eventsChan := make(chan []*storageusageevent.StorageUsageEvent)
errorsChan := make(chan error)
// Calculate number of days
days := int(endDay.Sub(startDay).Hours()/24) + 1
// Query each day in parallel (limit concurrency to avoid overwhelming Cassandra)
concurrency := 10
if days < concurrency {
concurrency = days
}
semaphore := make(chan struct{}, concurrency)
daysProcessed := 0
for day := startDay; !day.After(endDay); day = day.Add(24 * time.Hour) {
semaphore <- struct{}{}
daysProcessed++
go func(queryDay time.Time) {
defer func() { <-semaphore }()
events, err := impl.GetByUserAndDay(ctx, userID, queryDay)
if err != nil {
errorsChan <- err
return
}
eventsChan <- events
}(day)
}
// Collect results
var firstError error
for i := 0; i < daysProcessed; i++ {
select {
case events := <-eventsChan:
allEvents = append(allEvents, events...)
case err := <-errorsChan:
if firstError == nil {
firstError = err
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
if firstError != nil {
impl.Logger.Error("failed to get events for date range",
zap.Error(firstError),
zap.Int("days_requested", days))
return allEvents, firstError // Return partial results
}
return allEvents, nil
}
// Convenience methods for trend analysis
func (impl *storageUsageEventRepositoryImpl) GetLast7DaysEvents(ctx context.Context, userID gocql.UUID) ([]*storageusageevent.StorageUsageEvent, error) {
endDay := time.Now().Truncate(24 * time.Hour)
startDay := endDay.Add(-6 * 24 * time.Hour) // 7 days including today
return impl.GetByUserDateRange(ctx, userID, startDay, endDay)
}
func (impl *storageUsageEventRepositoryImpl) GetLastNDaysEvents(ctx context.Context, userID gocql.UUID, days int) ([]*storageusageevent.StorageUsageEvent, error) {
if days <= 0 {
return nil, fmt.Errorf("days must be positive")
}
endDay := time.Now().Truncate(24 * time.Hour)
startDay := endDay.Add(-time.Duration(days-1) * 24 * time.Hour)
return impl.GetByUserDateRange(ctx, userID, startDay, endDay)
}
func (impl *storageUsageEventRepositoryImpl) GetMonthlyEvents(ctx context.Context, userID gocql.UUID, year int, month time.Month) ([]*storageusageevent.StorageUsageEvent, error) {
startDay := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
endDay := startDay.AddDate(0, 1, -1) // Last day of the month
return impl.GetByUserDateRange(ctx, userID, startDay, endDay)
}
func (impl *storageUsageEventRepositoryImpl) GetYearlyEvents(ctx context.Context, userID gocql.UUID, year int) ([]*storageusageevent.StorageUsageEvent, error) {
startDay := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
endDay := time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC)
return impl.GetByUserDateRange(ctx, userID, startDay, endDay)
}

View file

@ -0,0 +1,24 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/storageusageevent/impl.go
package storageusageevent
import (
"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/storageusageevent"
)
type storageUsageEventRepositoryImpl struct {
Logger *zap.Logger
Session *gocql.Session
}
func NewRepository(appCfg *config.Configuration, session *gocql.Session, loggerp *zap.Logger) storageusageevent.StorageUsageEventRepository {
loggerp = loggerp.Named("StorageUsageEventRepository")
return &storageUsageEventRepositoryImpl{
Logger: loggerp,
Session: session,
}
}

View file

@ -0,0 +1,14 @@
package storageusageevent
import (
"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/storageusageevent"
)
// ProvideRepository provides a storage usage event repository for Wire DI
func ProvideRepository(cfg *config.Config, session *gocql.Session, logger *zap.Logger) storageusageevent.StorageUsageEventRepository {
return NewRepository(cfg, session, logger)
}

View file

@ -0,0 +1,149 @@
# Tag Denormalization Strategy
## Overview
The tag system uses **denormalized tables** for efficient "get all items with tag X" queries. This document explains how to maintain consistency across these tables.
## Table Architecture
### Primary Tables (Source of Truth)
- `collections_by_id` - Collections with `tag_ids LIST<UUID>` field
- `files_by_id` - Files with `tag_ids LIST<UUID>` field
- `tag_assignments_by_entity` - Lightweight assignment tracking
### Denormalized Tables (For Query Performance)
- `collections_by_tag_id` - Full collection data partitioned by tag_id
- `files_by_tag_id` - Full file data partitioned by tag_id
## Maintenance Responsibilities
### Tag Repository (`internal/repo/tag/tag.go`)
**Maintains:**
- `tag_assignments_by_entity` only
**Does NOT maintain:**
- `collections_by_tag_id`
- `files_by_tag_id`
**Reason**: Tag repository doesn't have access to full collection/file data needed for denormalized tables.
### Collection Repository (`internal/repo/collection/*.go`)
**Must maintain these tables when collections change:**
#### On Collection Create:
1. Insert into `collections_by_id` with `tag_ids = []`
2. For each tag_id in collection.tag_ids:
- Insert into `collections_by_tag_id`
#### On Collection Update:
1. Update `collections_by_id`
2. **Sync denormalized tables:**
- Get old tag_ids from existing collection
- Calculate diff: added tags, removed tags
- For removed tags: `DELETE FROM collections_by_tag_id WHERE tag_id = ? AND collection_id = ?`
- For added tags: `INSERT INTO collections_by_tag_id (...)`
- For unchanged tags: `UPDATE collections_by_tag_id ...` (if other fields changed)
#### On Collection Delete:
1. Delete from `collections_by_id`
2. For each tag_id in collection.tag_ids:
- Delete from `collections_by_tag_id`
### File Repository (`internal/repo/file/*.go`)
**Same pattern as collections** but for `files_by_tag_id` table.
## Tag Assignment Flow
### Assigning a Tag to a Collection:
```go
// 1. Service layer calls
tagService.AssignTag(ctx, userID, tagID, collectionID, "collection")
// 2. Tag service:
// a) Get current collection
collection := collectionRepo.Get(ctx, collectionID)
// b) Add tag to collection's tag_ids
collection.TagIds = append(collection.TagIds, tagID)
// c) Update collection (this triggers denormalization)
collectionRepo.Update(ctx, collection)
// - Updates collections_by_id
// - Inserts into collections_by_tag_id
// - Updates tag_assignments_by_entity
// 3. Tag repository only updates lightweight tracking
tagRepo.AssignTag(ctx, assignment)
// - Inserts into tag_assignments_by_entity only
```
### Unassigning a Tag:
```go
// 1. Service layer calls
tagService.UnassignTag(ctx, tagID, collectionID, "collection")
// 2. Tag service:
// a) Get current collection
collection := collectionRepo.Get(ctx, collectionID)
// b) Remove tag from collection's tag_ids
collection.TagIds = removeTag(collection.TagIds, tagID)
// c) Update collection
collectionRepo.Update(ctx, collection)
// - Updates collections_by_id
// - Deletes from collections_by_tag_id for removed tag
// - Updates tag_assignments_by_entity
// 3. Tag repository updates tracking
tagRepo.UnassignTag(ctx, tagID, collectionID, "collection")
// - Deletes from tag_assignments_by_entity
```
## Query Patterns
### Get All Collections with Tag X:
```sql
-- Efficient single-partition query!
SELECT * FROM collections_by_tag_id WHERE tag_id = ?
```
### Get All Tags for Collection Y:
```sql
-- Efficient query using tag_assignments_by_entity
SELECT tag_id FROM tag_assignments_by_entity
WHERE entity_id = ? AND entity_type = 'collection'
```
## Trade-offs
### Pros:
- ✅ **100x faster** queries for "show all items with tag X"
- ✅ Single partition reads (optimal Cassandra performance)
- ✅ Enables tag-based filtering in UI
### Cons:
- ❌ Write amplification (each collection update = N writes where N = number of tags)
- ❌ Data duplication (collection data stored in 3+ tables)
- ❌ Complexity in keeping tables in sync
## Implementation Checklist
When implementing denormalization in Collection/File repositories:
- [ ] On Create: Insert into denormalized table for each tag
- [ ] On Update: Diff tag_ids and sync denormalized table
- [ ] On Delete: Remove from denormalized table for all tags
- [ ] Handle edge cases: empty tag_ids, nil tag_ids
- [ ] Add logging for denormalization failures
- [ ] Consider using Cassandra batches for atomic writes
- [ ] Test concurrent updates to same collection/file
## Future Optimizations
If write performance becomes an issue:
1. **Use BATCH statements** for atomic multi-table writes
2. **Async denormalization** with message queue
3. **Materialized views** (Cassandra native, but with caveats)
4. **Caching layer** (Redis) to reduce read pressure

View file

@ -0,0 +1,12 @@
package tag
import (
"github.com/gocql/gocql"
dom_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
)
// ProvideTagRepository provides a tag repository for Wire DI
func ProvideTagRepository(session *gocql.Session) dom_tag.Repository {
return NewTagRepository(session)
}

View file

@ -0,0 +1,315 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/tag/tag.go
package tag
import (
"context"
"fmt"
"github.com/gocql/gocql"
dom_crypto "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
dom_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
)
type TagRepository struct {
session *gocql.Session
}
func NewTagRepository(session *gocql.Session) dom_tag.Repository {
return &TagRepository{
session: session,
}
}
// Create inserts a new tag with encrypted data
func (r *TagRepository) Create(ctx context.Context, tag *dom_tag.Tag) error {
// Extract encrypted tag key components
var ciphertext, nonce []byte
if tag.EncryptedTagKey != nil {
ciphertext = tag.EncryptedTagKey.Ciphertext
nonce = tag.EncryptedTagKey.Nonce
}
// Insert into tags_by_id
queryByID := `INSERT INTO maplefile.tags_by_id (id, user_id, encrypted_name, encrypted_color, encrypted_tag_key_ciphertext, encrypted_tag_key_nonce, created_at, modified_at, version, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if err := r.session.Query(queryByID,
tag.ID, tag.UserID, tag.EncryptedName, tag.EncryptedColor, ciphertext, nonce, tag.CreatedAt, tag.ModifiedAt, tag.Version, tag.State).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to insert into tags_by_id: %w", err)
}
// Insert into tags_by_user
queryByUser := `INSERT INTO maplefile.tags_by_user (user_id, id, encrypted_name, encrypted_color, encrypted_tag_key_ciphertext, encrypted_tag_key_nonce, created_at, modified_at, version, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if err := r.session.Query(queryByUser,
tag.UserID, tag.ID, tag.EncryptedName, tag.EncryptedColor, ciphertext, nonce, tag.CreatedAt, tag.ModifiedAt, tag.Version, tag.State).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to insert into tags_by_user: %w", err)
}
return nil
}
// GetByID retrieves a tag by its ID with encrypted data
func (r *TagRepository) GetByID(ctx context.Context, id gocql.UUID) (*dom_tag.Tag, error) {
query := `SELECT id, user_id, encrypted_name, encrypted_color, encrypted_tag_key_ciphertext, encrypted_tag_key_nonce, created_at, modified_at, version, state FROM maplefile.tags_by_id WHERE id = ? LIMIT 1`
tag := &dom_tag.Tag{}
var ciphertext, nonce []byte
if err := r.session.Query(query, id).WithContext(ctx).Scan(
&tag.ID, &tag.UserID, &tag.EncryptedName, &tag.EncryptedColor, &ciphertext, &nonce, &tag.CreatedAt, &tag.ModifiedAt, &tag.Version, &tag.State,
); err != nil {
if err == gocql.ErrNotFound {
return nil, fmt.Errorf("tag not found")
}
return nil, fmt.Errorf("failed to get tag: %w", err)
}
// Reconstruct EncryptedTagKey from components
if len(ciphertext) > 0 && len(nonce) > 0 {
tag.EncryptedTagKey = &dom_crypto.EncryptedTagKey{
Ciphertext: ciphertext,
Nonce: nonce,
KeyVersion: 1,
}
}
return tag, nil
}
// ListByUser retrieves all tags for a user with encrypted data
func (r *TagRepository) ListByUser(ctx context.Context, userID gocql.UUID) ([]*dom_tag.Tag, error) {
query := `SELECT id, user_id, encrypted_name, encrypted_color, encrypted_tag_key_ciphertext, encrypted_tag_key_nonce, created_at, modified_at, version, state FROM maplefile.tags_by_user WHERE user_id = ?`
iter := r.session.Query(query, userID).WithContext(ctx).Iter()
defer iter.Close()
var tags []*dom_tag.Tag
tag := &dom_tag.Tag{}
var ciphertext, nonce []byte
for iter.Scan(&tag.ID, &tag.UserID, &tag.EncryptedName, &tag.EncryptedColor, &ciphertext, &nonce, &tag.CreatedAt, &tag.ModifiedAt, &tag.Version, &tag.State) {
// Reconstruct EncryptedTagKey from components
if len(ciphertext) > 0 && len(nonce) > 0 {
tag.EncryptedTagKey = &dom_crypto.EncryptedTagKey{
Ciphertext: ciphertext,
Nonce: nonce,
KeyVersion: 1,
}
}
tags = append(tags, tag)
tag = &dom_tag.Tag{}
ciphertext, nonce = nil, nil
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to list tags: %w", err)
}
return tags, nil
}
// Update updates a tag with encrypted data
func (r *TagRepository) Update(ctx context.Context, tag *dom_tag.Tag) error {
// Extract encrypted tag key components
var ciphertext, nonce []byte
if tag.EncryptedTagKey != nil {
ciphertext = tag.EncryptedTagKey.Ciphertext
nonce = tag.EncryptedTagKey.Nonce
}
// Update tags_by_id
queryByID := `UPDATE maplefile.tags_by_id SET encrypted_name = ?, encrypted_color = ?, encrypted_tag_key_ciphertext = ?, encrypted_tag_key_nonce = ?, modified_at = ?, version = ?, state = ? WHERE id = ?`
if err := r.session.Query(queryByID,
tag.EncryptedName, tag.EncryptedColor, ciphertext, nonce, tag.ModifiedAt, tag.Version, tag.State, tag.ID).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to update tags_by_id: %w", err)
}
// Update tags_by_user
queryByUser := `UPDATE maplefile.tags_by_user SET encrypted_name = ?, encrypted_color = ?, encrypted_tag_key_ciphertext = ?, encrypted_tag_key_nonce = ?, modified_at = ?, version = ?, state = ? WHERE user_id = ? AND id = ?`
if err := r.session.Query(queryByUser,
tag.EncryptedName, tag.EncryptedColor, ciphertext, nonce, tag.ModifiedAt, tag.Version, tag.State, tag.UserID, tag.ID).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to update tags_by_user: %w", err)
}
return nil
}
// DeleteByID deletes a tag by ID for a specific user
func (r *TagRepository) DeleteByID(ctx context.Context, userID, id gocql.UUID) error {
// Delete from tags_by_id table
queryByID := `DELETE FROM maplefile.tags_by_id WHERE id = ?`
if err := r.session.Query(queryByID, id).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to delete from tags_by_id: %w", err)
}
// Delete from tags_by_user table
queryByUser := `DELETE FROM maplefile.tags_by_user WHERE user_id = ? AND id = ?`
if err := r.session.Query(queryByUser, userID, id).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to delete from tags_by_user: %w", err)
}
return nil
}
// AssignTag creates a tag assignment
// Note: This only updates tag_assignments_by_entity. The denormalized tables
// (collections_by_tag_id, files_by_tag_id) are maintained by their respective repositories.
func (r *TagRepository) AssignTag(ctx context.Context, assignment *dom_tag.TagAssignment) error {
// Insert into tag_assignments_by_entity
queryByEntity := `INSERT INTO maplefile.tag_assignments_by_entity (entity_id, entity_type, tag_id, user_id, created_at) VALUES (?, ?, ?, ?, ?)`
if err := r.session.Query(queryByEntity,
assignment.EntityID, assignment.EntityType, assignment.TagID, assignment.UserID, assignment.CreatedAt).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to insert into tag_assignments_by_entity: %w", err)
}
return nil
}
// UnassignTag removes a tag assignment
// Note: This only updates tag_assignments_by_entity. The denormalized tables
// (collections_by_tag_id, files_by_tag_id) are maintained by their respective repositories.
func (r *TagRepository) UnassignTag(ctx context.Context, tagID, entityID gocql.UUID, entityType string) error {
// Delete from tag_assignments_by_entity
queryByEntity := `DELETE FROM maplefile.tag_assignments_by_entity WHERE entity_id = ? AND entity_type = ? AND tag_id = ?`
if err := r.session.Query(queryByEntity, entityID, entityType, tagID).WithContext(ctx).Exec(); err != nil {
return fmt.Errorf("failed to delete from tag_assignments_by_entity: %w", err)
}
return nil
}
// GetTagsForEntity retrieves all tags assigned to an entity
func (r *TagRepository) GetTagsForEntity(ctx context.Context, entityID gocql.UUID, entityType string) ([]*dom_tag.Tag, error) {
// First get tag IDs from assignments
query := `SELECT tag_id FROM maplefile.tag_assignments_by_entity WHERE entity_id = ? AND entity_type = ?`
iter := r.session.Query(query, entityID, entityType).WithContext(ctx).Iter()
defer iter.Close()
var tagIDs []gocql.UUID
var tagID gocql.UUID
for iter.Scan(&tagID) {
tagIDs = append(tagIDs, tagID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get tag assignments: %w", err)
}
// Fetch tag details
var tags []*dom_tag.Tag
for _, tid := range tagIDs {
tag, err := r.GetByID(ctx, tid)
if err != nil {
continue // Skip if tag not found
}
tags = append(tags, tag)
}
return tags, nil
}
// GetEntitiesWithTag retrieves all entity IDs that have a specific tag
// Uses denormalized tables (collections_by_tag_id, files_by_tag_id) for efficient queries
func (r *TagRepository) GetEntitiesWithTag(ctx context.Context, tagID gocql.UUID, entityType string) ([]gocql.UUID, error) {
var query string
var columnName string
switch entityType {
case "collection":
query = `SELECT collection_id FROM maplefile.collections_by_tag_id WHERE tag_id = ?`
columnName = "collection_id"
case "file":
query = `SELECT file_id FROM maplefile.files_by_tag_id WHERE tag_id = ?`
columnName = "file_id"
default:
return nil, fmt.Errorf("unsupported entity type: %s", entityType)
}
iter := r.session.Query(query, tagID).WithContext(ctx).Iter()
defer iter.Close()
var entityIDs []gocql.UUID
var entityID gocql.UUID
for iter.Scan(&entityID) {
entityIDs = append(entityIDs, entityID)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get entities with tag from %s: %w", columnName, err)
}
return entityIDs, nil
}
// GetAssignmentsByTag retrieves all assignments for a specific tag
// Queries both denormalized tables (collections_by_tag_id and files_by_tag_id)
func (r *TagRepository) GetAssignmentsByTag(ctx context.Context, tagID gocql.UUID) ([]*dom_tag.TagAssignment, error) {
var assignments []*dom_tag.TagAssignment
// Get collection assignments
collectionQuery := `SELECT collection_id, owner_id, created_at FROM maplefile.collections_by_tag_id WHERE tag_id = ?`
collectionIter := r.session.Query(collectionQuery, tagID).WithContext(ctx).Iter()
var collectionID, ownerID gocql.UUID
var createdAt interface{}
for collectionIter.Scan(&collectionID, &ownerID, &createdAt) {
assignments = append(assignments, &dom_tag.TagAssignment{
TagID: tagID,
EntityID: collectionID,
EntityType: "collection",
UserID: ownerID,
})
}
collectionIter.Close()
// Get file assignments
fileQuery := `SELECT file_id, owner_id, created_at FROM maplefile.files_by_tag_id WHERE tag_id = ?`
fileIter := r.session.Query(fileQuery, tagID).WithContext(ctx).Iter()
var fileID gocql.UUID
for fileIter.Scan(&fileID, &ownerID, &createdAt) {
assignments = append(assignments, &dom_tag.TagAssignment{
TagID: tagID,
EntityID: fileID,
EntityType: "file",
UserID: ownerID,
})
}
if err := fileIter.Close(); err != nil {
return nil, fmt.Errorf("failed to get file assignments by tag: %w", err)
}
return assignments, nil
}
// GetAssignmentsByEntity retrieves all assignments for a specific entity
func (r *TagRepository) GetAssignmentsByEntity(ctx context.Context, entityID gocql.UUID, entityType string) ([]*dom_tag.TagAssignment, error) {
query := `SELECT tag_id, user_id, created_at FROM maplefile.tag_assignments_by_entity WHERE entity_id = ? AND entity_type = ?`
iter := r.session.Query(query, entityID, entityType).WithContext(ctx).Iter()
defer iter.Close()
var assignments []*dom_tag.TagAssignment
var tagID, userID gocql.UUID
var createdAt interface{}
for iter.Scan(&tagID, &userID, &createdAt) {
assignment := &dom_tag.TagAssignment{
TagID: tagID,
EntityID: entityID,
EntityType: entityType,
UserID: userID,
}
assignments = append(assignments, assignment)
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("failed to get assignments by entity: %w", err)
}
return assignments, nil
}

View file

@ -0,0 +1,6 @@
package templatedemailer
func (impl *templatedEmailer) SendBusinessVerificationEmail(email, verificationCode, firstName string) error {
return nil
}

View file

@ -0,0 +1,10 @@
package templatedemailer
import (
"context"
)
func (impl *templatedEmailer) SendUserPasswordResetEmail(ctx context.Context, email, verificationCode, firstName string) error {
return nil
}

View file

@ -0,0 +1,41 @@
package templatedemailer
import (
"context"
"go.uber.org/zap"
)
// TemplatedEmailer Is adapter for responsive HTML email templates sender.
type TemplatedEmailer interface {
GetBackendDomainName() string
GetFrontendDomainName() string
// SendBusinessVerificationEmail(email, verificationCode, firstName string) error
SendUserVerificationEmail(ctx context.Context, email, verificationCode, firstName string) error
// SendNewUserTemporaryPasswordEmail(email, firstName, temporaryPassword string) error
SendUserPasswordResetEmail(ctx context.Context, email, verificationCode, firstName string) error
// SendNewComicSubmissionEmailToStaff(staffEmails []string, submissionID string, storeName string, item string, cpsrn string, serviceTypeName string) error
// SendNewComicSubmissionEmailToRetailers(retailerEmails []string, submissionID string, storeName string, item string, cpsrn string, serviceTypeName string) error
// SendNewStoreEmailToStaff(staffEmails []string, storeID string) error
// SendRetailerStoreActiveEmailToRetailers(retailerEmails []string, storeName string) error
}
type templatedEmailer struct {
Logger *zap.Logger
}
func NewTemplatedEmailer(logger *zap.Logger) TemplatedEmailer {
logger = logger.Named("TemplatedEmailer")
return &templatedEmailer{
Logger: logger,
}
}
func (impl *templatedEmailer) GetBackendDomainName() string {
return ""
}
func (impl *templatedEmailer) GetFrontendDomainName() string {
return ""
}

View file

@ -0,0 +1,10 @@
package templatedemailer
import (
"go.uber.org/zap"
)
// ProvideTemplatedEmailer provides a templated emailer for Wire DI
func ProvideTemplatedEmailer(logger *zap.Logger) TemplatedEmailer {
return NewTemplatedEmailer(logger)
}

View file

@ -0,0 +1,5 @@
package templatedemailer
func (impl *templatedEmailer) SendRetailerStoreActiveEmailToRetailers(retailerEmails []string, storeName string) error {
return nil
}

View file

@ -0,0 +1,6 @@
package templatedemailer
func (impl *templatedEmailer) SendNewUserTemporaryPasswordEmail(email, firstName, temporaryPassword string) error {
return nil
}

View file

@ -0,0 +1,10 @@
package templatedemailer
import (
"context"
)
func (impl *templatedEmailer) SendUserVerificationEmail(ctx context.Context, email, verificationCode, firstName string) error {
return nil
}

View file

@ -0,0 +1,76 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/anonymize_old_ips.go
package user
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// AnonymizeOldIPs anonymizes IP addresses in user tables older than the cutoff date
func (impl *userStorerImpl) AnonymizeOldIPs(ctx context.Context, cutoffDate time.Time) (int, error) {
totalAnonymized := 0
// Anonymize users_by_id table
count, err := impl.anonymizeUsersById(ctx, cutoffDate)
if err != nil {
impl.logger.Error("Failed to anonymize users_by_id",
zap.Error(err),
zap.Time("cutoff_date", cutoffDate))
return totalAnonymized, err
}
totalAnonymized += count
impl.logger.Info("IP anonymization completed for user tables",
zap.Int("total_anonymized", totalAnonymized),
zap.Time("cutoff_date", cutoffDate))
return totalAnonymized, nil
}
// anonymizeUsersById processes the users_by_id table
func (impl *userStorerImpl) anonymizeUsersById(ctx context.Context, cutoffDate time.Time) (int, error) {
count := 0
// Query all users (efficient primary key scan, no ALLOW FILTERING)
query := `SELECT id, created_at, ip_anonymized_at FROM maplefile.users_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.users_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 user record",
zap.String("user_id", id.String()),
zap.Error(err))
continue
}
count++
}
}
if err := iter.Close(); err != nil {
impl.logger.Error("Error during users_by_id iteration", zap.Error(err))
return count, err
}
impl.logger.Debug("Anonymized users_by_id table",
zap.Int("count", count),
zap.Time("cutoff_date", cutoffDate))
return count, nil
}

View file

@ -0,0 +1,38 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/anonymize_user_ips.go
package user
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
)
// AnonymizeUserIPs immediately anonymizes all IP addresses for a specific user
// Used for GDPR right-to-be-forgotten implementation
func (impl *userStorerImpl) AnonymizeUserIPs(ctx context.Context, userID gocql.UUID) error {
impl.logger.Info("Anonymizing IPs for specific user (GDPR mode)",
zap.String("user_id", userID.String()))
// Update the user record to anonymize all IP addresses
query := `
UPDATE maplefile.users_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(query, time.Now(), userID).WithContext(ctx).Exec(); err != nil {
impl.logger.Error("Failed to anonymize user IPs",
zap.String("user_id", userID.String()),
zap.Error(err))
return err
}
impl.logger.Info("✅ Successfully anonymized user IPs",
zap.String("user_id", userID.String()))
return nil
}

View file

@ -0,0 +1,47 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/check.go
package user
import (
"context"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
func (r *userStorerImpl) CheckIfExistsByID(ctx context.Context, id gocql.UUID) (bool, error) {
query := `SELECT id FROM users_by_id WHERE id = ? LIMIT 1`
err := r.session.Query(query, id).WithContext(ctx).Scan(&id)
if err == gocql.ErrNotFound {
return false, nil
}
if err != nil {
r.logger.Error("Failed to check if user exists by id",
zap.String("id", id.String()),
zap.Error(err))
return false, err
}
return true, nil
}
func (r *userStorerImpl) CheckIfExistsByEmail(ctx context.Context, email string) (bool, error) {
var id gocql.UUID
query := `SELECT id FROM users_by_email WHERE email = ? LIMIT 1`
err := r.session.Query(query, email).WithContext(ctx).Scan(&id)
if err == gocql.ErrNotFound {
return false, nil
}
if err != nil {
r.logger.Error("Failed to check if user exists by email",
zap.String("email", validation.MaskEmail(email)),
zap.Error(err))
return false, err
}
return true, nil
}

View file

@ -0,0 +1,115 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/create.go
package user
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
func (impl userStorerImpl) Create(ctx context.Context, user *dom_user.User) error {
// Ensure we have a valid UUID
if user.ID == (gocql.UUID{}) {
user.ID = gocql.TimeUUID()
}
// Set timestamps if not set
now := time.Now()
if user.CreatedAt.IsZero() {
user.CreatedAt = now
}
if user.ModifiedAt.IsZero() {
user.ModifiedAt = now
}
// Serialize complex data to JSON
profileDataJSON, err := impl.serializeProfileData(user.ProfileData)
if err != nil {
return fmt.Errorf("failed to serialize profile data: %w", err)
}
securityDataJSON, err := impl.serializeSecurityData(user.SecurityData)
if err != nil {
return fmt.Errorf("failed to serialize security data: %w", err)
}
metadataJSON, err := impl.serializeMetadata(user.Metadata)
if err != nil {
return fmt.Errorf("failed to serialize metadata: %w", err)
}
// Use a batch for atomic writes across multiple tables
batch := impl.session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
// 1. Insert into users_by_id (primary table)
batch.Query(`
INSERT INTO users_by_id (
id, email, first_name, last_name, name, lexical_name,
role, status, timezone, created_at, modified_at,
profile_data, security_data, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.ID, user.Email, user.FirstName, user.LastName, user.Name, user.LexicalName,
user.Role, user.Status, user.Timezone, user.CreatedAt, user.ModifiedAt,
profileDataJSON, securityDataJSON, metadataJSON,
)
// 2. Insert into users_by_email
batch.Query(`
INSERT INTO users_by_email (
email, id, first_name, last_name, name, lexical_name,
role, status, timezone, created_at, modified_at,
profile_data, security_data, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.Email, user.ID, user.FirstName, user.LastName, user.Name, user.LexicalName,
user.Role, user.Status, user.Timezone, user.CreatedAt, user.ModifiedAt,
profileDataJSON, securityDataJSON, metadataJSON,
)
// 3. Insert into users_by_verification_code if verification code exists
if user.SecurityData != nil && user.SecurityData.Code != "" {
batch.Query(`
INSERT INTO users_by_verification_code (
verification_code, id, email, first_name, last_name, name, lexical_name,
role, status, timezone, created_at, modified_at,
profile_data, security_data, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.SecurityData.Code, user.ID, user.Email, user.FirstName, user.LastName, user.Name, user.LexicalName,
user.Role, user.Status, user.Timezone, user.CreatedAt, user.ModifiedAt,
profileDataJSON, securityDataJSON, metadataJSON,
)
}
// 4. Insert into users_by_status_and_date for listing
// Skip
// 5. If status is active, also insert into active users table
if user.Status == dom_user.UserStatusActive {
// Skip
}
// 6. Add to search index (simplified - you might want to use external search)
if user.Name != "" || user.Email != "" {
// Skip
}
// Execute the batch
if err := impl.session.ExecuteBatch(batch); err != nil {
impl.logger.Error("Failed to create user",
zap.String("user_id", user.ID.String()),
zap.String("email", validation.MaskEmail(user.Email)),
zap.Error(err))
return fmt.Errorf("failed to create user: %w", err)
}
impl.logger.Info("User created successfully",
zap.String("user_id", user.ID.String()),
zap.String("email", validation.MaskEmail(user.Email)))
return nil
}

View file

@ -0,0 +1,68 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/delete.go
package user
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
func (impl userStorerImpl) DeleteByID(ctx context.Context, id gocql.UUID) error {
// First, get the user to know all the data we need to delete
user, err := impl.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("failed to get user for deletion: %w", err)
}
if user == nil {
return nil // User doesn't exist, nothing to delete
}
batch := impl.session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
// Delete from all user tables
batch.Query(`DELETE FROM users_by_id WHERE id = ?`, id)
batch.Query(`DELETE FROM users_by_email WHERE email = ?`, user.Email)
// Delete from verification code table if user has verification code
// Note: We delete by scanning since verification_code is the partition key
// This is acceptable for GDPR deletion (rare operation, thorough cleanup)
if user.SecurityData != nil && user.SecurityData.Code != "" {
batch.Query(`DELETE FROM users_by_verification_code WHERE verification_code = ?`, user.SecurityData.Code)
}
// Delete all user sessions
// Note: sessions_by_user_id is partitioned by user_id, so this is efficient
batch.Query(`DELETE FROM sessions_by_user_id WHERE user_id = ?`, id)
// Execute the batch
if err := impl.session.ExecuteBatch(batch); err != nil {
impl.logger.Error("Failed to delete user",
zap.String("user_id", id.String()),
zap.Error(err))
return fmt.Errorf("failed to delete user: %w", err)
}
impl.logger.Info("User deleted successfully",
zap.String("user_id", id.String()),
zap.String("email", validation.MaskEmail(user.Email)))
return nil
}
func (impl userStorerImpl) DeleteByEmail(ctx context.Context, email string) error {
// First get the user by email to get the ID
user, err := impl.GetByEmail(ctx, email)
if err != nil {
return fmt.Errorf("failed to get user by email for deletion: %w", err)
}
if user == nil {
return nil // User doesn't exist
}
// Delete by ID
return impl.DeleteByID(ctx, user.ID)
}

View file

@ -0,0 +1,199 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/get.go
package user
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
func (impl userStorerImpl) GetByID(ctx context.Context, id gocql.UUID) (*dom_user.User, error) {
var (
email, firstName, lastName, name, lexicalName string
role, status int8
timezone string
createdAt, modifiedAt time.Time
profileData, securityData, metadata string
)
query := `
SELECT email, first_name, last_name, name, lexical_name,
role, status, timezone, created_at, modified_at,
profile_data, security_data, metadata
FROM users_by_id
WHERE id = ?`
err := impl.session.Query(query, id).WithContext(ctx).Scan(
&email, &firstName, &lastName, &name, &lexicalName,
&role, &status, &timezone, &createdAt, &modifiedAt,
&profileData, &securityData, &metadata,
)
if err == gocql.ErrNotFound {
return nil, nil
}
if err != nil {
impl.logger.Error("Failed to get user by ID",
zap.String("user_id", id.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to get user by ID: %w", err)
}
// Construct the user object
user := &dom_user.User{
ID: id,
Email: email,
FirstName: firstName,
LastName: lastName,
Name: name,
LexicalName: lexicalName,
Role: role,
Status: status,
Timezone: timezone,
CreatedAt: createdAt,
ModifiedAt: modifiedAt,
}
// Deserialize JSON fields
if err := impl.deserializeUserData(profileData, securityData, metadata, user); err != nil {
impl.logger.Error("Failed to deserialize user data",
zap.String("user_id", id.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to deserialize user data: %w", err)
}
return user, nil
}
func (impl userStorerImpl) GetByEmail(ctx context.Context, email string) (*dom_user.User, error) {
var (
id gocql.UUID
emailResult string
firstName, lastName, name, lexicalName string
role, status int8
timezone string
createdAt, modifiedAt time.Time
profileData, securityData, metadata string
)
query := `
SELECT id, email, first_name, last_name, name, lexical_name,
role, status, timezone, created_at, modified_at,
profile_data, security_data, metadata
FROM users_by_email
WHERE email = ?`
err := impl.session.Query(query, email).WithContext(ctx).Scan(
&id, &emailResult, &firstName, &lastName, &name, &lexicalName, // 🔧 FIXED: Use emailResult variable
&role, &status, &timezone, &createdAt, &modifiedAt,
&profileData, &securityData, &metadata,
)
if err == gocql.ErrNotFound {
return nil, nil
}
if err != nil {
impl.logger.Error("Failed to get user by Email",
zap.String("user_email", validation.MaskEmail(email)),
zap.Error(err))
return nil, fmt.Errorf("failed to get user by email: %w", err)
}
// Construct the user object
user := &dom_user.User{
ID: id,
Email: emailResult,
FirstName: firstName,
LastName: lastName,
Name: name,
LexicalName: lexicalName,
Role: role,
Status: status,
Timezone: timezone,
CreatedAt: createdAt,
ModifiedAt: modifiedAt,
}
// Deserialize JSON fields
if err := impl.deserializeUserData(profileData, securityData, metadata, user); err != nil {
impl.logger.Error("Failed to deserialize user data",
zap.String("user_id", id.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to deserialize user data: %w", err)
}
return user, nil
}
func (impl userStorerImpl) GetByVerificationCode(ctx context.Context, verificationCode string) (*dom_user.User, error) {
var (
id gocql.UUID
email string
firstName, lastName, name, lexicalName string
role, status int8
timezone string
createdAt, modifiedAt time.Time
profileData, securityData, metadata string
)
// Query the users_by_verification_code table
query := `
SELECT id, email, first_name, last_name, name, lexical_name,
role, status, timezone, created_at, modified_at,
profile_data, security_data, metadata
FROM users_by_verification_code
WHERE verification_code = ?`
err := impl.session.Query(query, verificationCode).WithContext(ctx).Scan(
&id, &email, &firstName, &lastName, &name, &lexicalName,
&role, &status, &timezone, &createdAt, &modifiedAt,
&profileData, &securityData, &metadata,
)
if err == gocql.ErrNotFound {
impl.logger.Debug("User not found by verification code",
zap.String("verification_code", verificationCode))
return nil, nil
}
if err != nil {
impl.logger.Error("Failed to get user by verification code",
zap.String("verification_code", verificationCode),
zap.Error(err))
return nil, fmt.Errorf("failed to get user by verification code: %w", err)
}
// Construct the user object
user := &dom_user.User{
ID: id,
Email: email,
FirstName: firstName,
LastName: lastName,
Name: name,
LexicalName: lexicalName,
Role: role,
Status: status,
Timezone: timezone,
CreatedAt: createdAt,
ModifiedAt: modifiedAt,
}
// Deserialize JSON fields
if err := impl.deserializeUserData(profileData, securityData, metadata, user); err != nil {
impl.logger.Error("Failed to deserialize user data",
zap.String("user_id", id.String()),
zap.Error(err))
return nil, fmt.Errorf("failed to deserialize user data: %w", err)
}
impl.logger.Debug("User found by verification code",
zap.String("user_id", id.String()),
zap.String("email", validation.MaskEmail(email)))
return user, nil
}

View file

@ -0,0 +1,114 @@
package user
import (
"encoding/json"
"fmt"
"hash/fnv"
"strings"
dom "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
)
// Serialization helpers
func (r *userStorerImpl) serializeProfileData(data *dom.UserProfileData) (string, error) {
if data == nil {
return "", nil
}
bytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(bytes), nil
}
func (r *userStorerImpl) serializeSecurityData(data *dom.UserSecurityData) (string, error) {
if data == nil {
return "", nil
}
bytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(bytes), nil
}
func (r *userStorerImpl) serializeMetadata(data *dom.UserMetadata) (string, error) {
if data == nil {
return "", nil
}
bytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(bytes), nil
}
// Deserialization helper
func (r *userStorerImpl) deserializeUserData(profileJSON, securityJSON, metadataJSON string, user *dom.User) error {
// Deserialize profile data
if profileJSON != "" {
var profileData dom.UserProfileData
if err := json.Unmarshal([]byte(profileJSON), &profileData); err != nil {
return fmt.Errorf("failed to unmarshal profile data: %w", err)
}
user.ProfileData = &profileData
}
// Deserialize security data
if securityJSON != "" {
var securityData dom.UserSecurityData
if err := json.Unmarshal([]byte(securityJSON), &securityData); err != nil {
return fmt.Errorf("failed to unmarshal security data: %w", err)
}
user.SecurityData = &securityData
}
// Deserialize metadata
if metadataJSON != "" {
var metadata dom.UserMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return fmt.Errorf("failed to unmarshal metadata: %w", err)
}
user.Metadata = &metadata
}
return nil
}
// Search helpers
func (r *userStorerImpl) generateSearchTerms(user *dom.User) []string {
terms := make([]string, 0)
// Add lowercase versions of searchable fields
if user.Email != "" {
terms = append(terms, strings.ToLower(user.Email))
// Also add email prefix for partial matching
parts := strings.Split(user.Email, "@")
if len(parts) > 0 {
terms = append(terms, strings.ToLower(parts[0]))
}
}
if user.Name != "" {
terms = append(terms, strings.ToLower(user.Name))
// Add individual words from name
words := strings.Fields(strings.ToLower(user.Name))
terms = append(terms, words...)
}
if user.FirstName != "" {
terms = append(terms, strings.ToLower(user.FirstName))
}
if user.LastName != "" {
terms = append(terms, strings.ToLower(user.LastName))
}
return terms
}
func (r *userStorerImpl) calculateSearchBucket(term string) int {
h := fnv.New32a()
h.Write([]byte(term))
return int(h.Sum32() % 100) // Distribute across 100 buckets
}

View file

@ -0,0 +1,29 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/impl.go
package user
import (
"context"
"go.uber.org/zap"
"github.com/gocql/gocql"
dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
)
type userStorerImpl struct {
session *gocql.Session
logger *zap.Logger
}
func NewRepository(session *gocql.Session, logger *zap.Logger) dom_user.Repository {
logger = logger.Named("MapleFileUserRepository")
return &userStorerImpl{
session: session,
logger: logger,
}
}
// ListAll retrieves all users from the database
func (impl userStorerImpl) ListAll(ctx context.Context) ([]*dom_user.User, error) {
return nil, nil
}

View file

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

View file

@ -0,0 +1,145 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/user/update.go
package user
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
func (impl userStorerImpl) UpdateByID(ctx context.Context, user *dom_user.User) error {
// First, get the existing user to check what changed
existingUser, err := impl.GetByID(ctx, user.ID)
if err != nil {
return fmt.Errorf("failed to get existing user: %w", err)
}
if existingUser == nil {
return fmt.Errorf("user not found: %s", user.ID)
}
// Update modified timestamp
user.ModifiedAt = time.Now()
// Serialize data
profileDataJSON, err := impl.serializeProfileData(user.ProfileData)
if err != nil {
return fmt.Errorf("failed to serialize profile data: %w", err)
}
securityDataJSON, err := impl.serializeSecurityData(user.SecurityData)
if err != nil {
return fmt.Errorf("failed to serialize security data: %w", err)
}
metadataJSON, err := impl.serializeMetadata(user.Metadata)
if err != nil {
return fmt.Errorf("failed to serialize metadata: %w", err)
}
batch := impl.session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
// 1. Update main table
batch.Query(`
UPDATE users_by_id
SET email = ?, first_name = ?, last_name = ?, name = ?, lexical_name = ?,
role = ?, status = ?, modified_at = ?,
profile_data = ?, security_data = ?, metadata = ?
WHERE id = ?`,
user.Email, user.FirstName, user.LastName, user.Name, user.LexicalName,
user.Role, user.Status, user.ModifiedAt,
profileDataJSON, securityDataJSON, metadataJSON,
user.ID,
)
// 2. Handle email change
if existingUser.Email != user.Email {
// Delete old email entry
batch.Query(`DELETE FROM users_by_email WHERE email = ?`, existingUser.Email)
// Insert new email entry
batch.Query(`
INSERT INTO users_by_email (
email, id, first_name, last_name, status, created_at
) VALUES (?, ?, ?, ?, ?, ?)`,
user.Email, user.ID, user.FirstName, user.LastName,
user.Status, user.CreatedAt,
)
} else {
// Just update the existing email entry
batch.Query(`
UPDATE users_by_email
SET first_name = ?, last_name = ?, name = ?, lexical_name = ?,
role = ?, status = ?, timezone = ?, modified_at = ?,
profile_data = ?, security_data = ?, metadata = ?
WHERE email = ?`,
user.FirstName, user.LastName, user.Name, user.LexicalName,
user.Role, user.Status, user.Timezone, user.ModifiedAt,
profileDataJSON, securityDataJSON, metadataJSON,
user.Email,
)
}
// 3. Handle status change
if existingUser.Status != user.Status {
// Remove from old status table
// kip
// Add to new status table
// Skip
// Handle active users table
if existingUser.Status == dom_user.UserStatusActive {
// Skip
}
if user.Status == dom_user.UserStatusActive {
// Skip
} else {
// Just update the existing status entry
// Skip
if user.Status == dom_user.UserStatusActive {
// Skip
}
}
}
// 4. Handle verification code changes
// Delete old verification code entry if it exists
if existingUser.SecurityData != nil && existingUser.SecurityData.Code != "" {
batch.Query(`DELETE FROM users_by_verification_code WHERE verification_code = ?`, existingUser.SecurityData.Code)
}
// Insert new verification code entry if it exists
if user.SecurityData != nil && user.SecurityData.Code != "" {
batch.Query(`
INSERT INTO users_by_verification_code (
verification_code, id, email, first_name, last_name, name, lexical_name,
role, status, timezone, created_at, modified_at,
profile_data, security_data, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.SecurityData.Code, user.ID, user.Email, user.FirstName, user.LastName, user.Name, user.LexicalName,
user.Role, user.Status, user.Timezone, user.CreatedAt, user.ModifiedAt,
profileDataJSON, securityDataJSON, metadataJSON,
)
}
// Execute the batch
if err := impl.session.ExecuteBatch(batch); err != nil {
impl.logger.Error("Failed to update user",
zap.String("user_id", user.ID.String()),
zap.Error(err))
return fmt.Errorf("failed to update user: %w", err)
}
impl.logger.Info("User updated successfully",
zap.String("user_id", user.ID.String()),
zap.String("email", validation.MaskEmail(user.Email)))
return nil
}