Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/repo/collection/anonymize_collection_ips.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AnonymizeCollectionIPsByOwner immediately anonymizes all IP addresses for collections owned by a specific user
|
||||
// Used for GDPR right-to-be-forgotten implementation
|
||||
func (impl *collectionRepositoryImpl) AnonymizeCollectionIPsByOwner(ctx context.Context, ownerID gocql.UUID) (int, error) {
|
||||
impl.Logger.Info("Anonymizing IPs for collections owned by user (GDPR mode)",
|
||||
zap.String("owner_id", ownerID.String()))
|
||||
|
||||
count := 0
|
||||
|
||||
// Query all collections owned by this user
|
||||
query := `SELECT id FROM maplefile.collections_by_id WHERE owner_id = ? ALLOW FILTERING`
|
||||
iter := impl.Session.Query(query, ownerID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
var collectionIDs []gocql.UUID
|
||||
|
||||
// Collect all collection IDs first
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("Error querying collections by owner", zap.Error(err))
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Anonymize IPs for each collection
|
||||
for _, colID := range collectionIDs {
|
||||
updateQuery := `
|
||||
UPDATE maplefile.collections_by_id
|
||||
SET created_from_ip_address = '0.0.0.0',
|
||||
modified_from_ip_address = '0.0.0.0',
|
||||
ip_anonymized_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
if err := impl.Session.Query(updateQuery, time.Now(), colID).WithContext(ctx).Exec(); err != nil {
|
||||
impl.Logger.Error("Failed to anonymize collection IPs",
|
||||
zap.String("collection_id", colID.String()),
|
||||
zap.Error(err))
|
||||
continue // Best-effort: continue with next collection
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
impl.Logger.Info("✅ Successfully anonymized collection IPs",
|
||||
zap.String("owner_id", ownerID.String()),
|
||||
zap.Int("collections_anonymized", count))
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/repo/collection/anonymize_old_ips.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AnonymizeOldIPs anonymizes IP addresses in collection tables older than the cutoff date
|
||||
func (impl *collectionRepositoryImpl) AnonymizeOldIPs(ctx context.Context, cutoffDate time.Time) (int, error) {
|
||||
totalAnonymized := 0
|
||||
|
||||
// Anonymize collections_by_id table (primary table)
|
||||
count, err := impl.anonymizeCollectionsById(ctx, cutoffDate)
|
||||
if err != nil {
|
||||
impl.Logger.Error("Failed to anonymize collections_by_id",
|
||||
zap.Error(err),
|
||||
zap.Time("cutoff_date", cutoffDate))
|
||||
return totalAnonymized, err
|
||||
}
|
||||
totalAnonymized += count
|
||||
|
||||
impl.Logger.Info("IP anonymization completed for collection tables",
|
||||
zap.Int("total_anonymized", totalAnonymized),
|
||||
zap.Time("cutoff_date", cutoffDate))
|
||||
|
||||
return totalAnonymized, nil
|
||||
}
|
||||
|
||||
// anonymizeCollectionsById processes the collections_by_id table
|
||||
func (impl *collectionRepositoryImpl) anonymizeCollectionsById(ctx context.Context, cutoffDate time.Time) (int, error) {
|
||||
count := 0
|
||||
|
||||
// Query all collections (efficient primary key scan, no ALLOW FILTERING)
|
||||
query := `SELECT id, created_at, ip_anonymized_at FROM maplefile.collections_by_id`
|
||||
iter := impl.Session.Query(query).WithContext(ctx).Iter()
|
||||
|
||||
var id gocql.UUID
|
||||
var createdAt time.Time
|
||||
var ipAnonymizedAt *time.Time
|
||||
|
||||
for iter.Scan(&id, &createdAt, &ipAnonymizedAt) {
|
||||
// Filter in application code: older than cutoff AND not yet anonymized
|
||||
if createdAt.Before(cutoffDate) && ipAnonymizedAt == nil {
|
||||
// Update the record to anonymize IPs
|
||||
updateQuery := `
|
||||
UPDATE maplefile.collections_by_id
|
||||
SET created_from_ip_address = '',
|
||||
modified_from_ip_address = '',
|
||||
ip_anonymized_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
if err := impl.Session.Query(updateQuery, time.Now(), id).WithContext(ctx).Exec(); err != nil {
|
||||
impl.Logger.Error("Failed to anonymize collection record",
|
||||
zap.String("collection_id", id.String()),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("Error during collections_by_id iteration", zap.Error(err))
|
||||
return count, err
|
||||
}
|
||||
|
||||
impl.Logger.Debug("Anonymized collections_by_id table",
|
||||
zap.Int("count", count),
|
||||
zap.Time("cutoff_date", cutoffDate))
|
||||
|
||||
return count, nil
|
||||
}
|
||||
34
cloud/maplefile-backend/internal/repo/collection/archive.go
Normal file
34
cloud/maplefile-backend/internal/repo/collection/archive.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/archive.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) Archive(ctx context.Context, id gocql.UUID) error {
|
||||
collection, err := impl.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection for archive: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
// Validate state transition
|
||||
if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateArchived); err != nil {
|
||||
return fmt.Errorf("invalid state transition: %w", err)
|
||||
}
|
||||
|
||||
// Update collection state
|
||||
collection.State = dom_collection.CollectionStateArchived
|
||||
collection.ModifiedAt = time.Now()
|
||||
collection.Version++
|
||||
|
||||
return impl.Update(ctx, collection)
|
||||
}
|
||||
160
cloud/maplefile-backend/internal/repo/collection/check.go
Normal file
160
cloud/maplefile-backend/internal/repo/collection/check.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/check.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) CheckIfExistsByID(ctx context.Context, id gocql.UUID) (bool, error) {
|
||||
var count int
|
||||
|
||||
query := `SELECT COUNT(*) FROM collections_by_id WHERE id = ?`
|
||||
|
||||
if err := impl.Session.Query(query, id).WithContext(ctx).Scan(&count); err != nil {
|
||||
return false, fmt.Errorf("failed to check collection existence: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// IsCollectionOwner demonstrates the memory-filtering approach for better performance
|
||||
// Instead of forcing Cassandra to scan with ALLOW FILTERING, we query efficiently and filter in memory
|
||||
func (impl *collectionRepositoryImpl) IsCollectionOwner(ctx context.Context, collectionID, userID gocql.UUID) (bool, error) {
|
||||
// Strategy: Use the compound partition key table to efficiently check ownership
|
||||
// This query is fast because both user_id and access_type are part of the partition key
|
||||
var collectionExists gocql.UUID
|
||||
|
||||
query := `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner' AND collection_id = ? LIMIT 1 ALLOW FILTERING`
|
||||
|
||||
err := impl.Session.Query(query, userID, collectionID).WithContext(ctx).Scan(&collectionExists)
|
||||
if err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to check ownership: %w", err)
|
||||
}
|
||||
|
||||
// If we got a result, the user is an owner of this collection
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Alternative implementation using the memory-filtering approach
|
||||
// This demonstrates a different strategy when you can't avoid some filtering
|
||||
func (impl *collectionRepositoryImpl) IsCollectionOwnerAlternative(ctx context.Context, collectionID, userID gocql.UUID) (bool, error) {
|
||||
// Memory-filtering approach: Get all collections for this user, filter for the specific collection
|
||||
// This is efficient when users don't have thousands of collections
|
||||
|
||||
query := `SELECT collection_id, access_type FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
|
||||
|
||||
var currentCollectionID gocql.UUID
|
||||
var accessType string
|
||||
|
||||
for iter.Scan(¤tCollectionID, &accessType) {
|
||||
// Check if this is the collection we're looking for and if the user is the owner
|
||||
if currentCollectionID == collectionID && accessType == "owner" {
|
||||
iter.Close()
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return false, fmt.Errorf("failed to check ownership: %w", err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// CheckAccess uses the efficient compound partition key approach
|
||||
func (impl *collectionRepositoryImpl) CheckAccess(ctx context.Context, collectionID, userID gocql.UUID, requiredPermission string) (bool, error) {
|
||||
// First check if user is owner (owners have all permissions)
|
||||
isOwner, err := impl.IsCollectionOwner(ctx, collectionID, userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check ownership: %w", err)
|
||||
}
|
||||
|
||||
if isOwner {
|
||||
return true, nil // Owners have all permissions
|
||||
}
|
||||
|
||||
// Check if user is a member with sufficient permissions
|
||||
var permissionLevel string
|
||||
|
||||
query := `SELECT permission_level FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'member' AND collection_id = ? LIMIT 1 ALLOW FILTERING`
|
||||
|
||||
err = impl.Session.Query(query, userID, collectionID).WithContext(ctx).Scan(&permissionLevel)
|
||||
if err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
return false, nil // No access
|
||||
}
|
||||
return false, fmt.Errorf("failed to check member access: %w", err)
|
||||
}
|
||||
|
||||
// Check if user's permission level meets requirement
|
||||
return impl.hasPermission(permissionLevel, requiredPermission), nil
|
||||
}
|
||||
|
||||
// GetUserPermissionLevel efficiently determines a user's permission level for a collection
|
||||
func (impl *collectionRepositoryImpl) GetUserPermissionLevel(ctx context.Context, collectionID, userID gocql.UUID) (string, error) {
|
||||
// Check ownership first using the efficient compound key table
|
||||
isOwner, err := impl.IsCollectionOwner(ctx, collectionID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check ownership: %w", err)
|
||||
}
|
||||
|
||||
if isOwner {
|
||||
return dom_collection.CollectionPermissionAdmin, nil
|
||||
}
|
||||
|
||||
// Check member permissions
|
||||
var permissionLevel string
|
||||
|
||||
query := `SELECT permission_level FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'member' AND collection_id = ? LIMIT 1 ALLOW FILTERING`
|
||||
|
||||
err = impl.Session.Query(query, userID, collectionID).WithContext(ctx).Scan(&permissionLevel)
|
||||
if err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
return "", nil // No access
|
||||
}
|
||||
return "", fmt.Errorf("failed to get permission level: %w", err)
|
||||
}
|
||||
|
||||
return permissionLevel, nil
|
||||
}
|
||||
|
||||
// Demonstration of a completely ALLOW FILTERING-free approach using direct collection lookup
|
||||
// This approach queries the main collection table and checks ownership directly
|
||||
func (impl *collectionRepositoryImpl) CheckAccessByCollectionLookup(ctx context.Context, collectionID, userID gocql.UUID, requiredPermission string) (bool, error) {
|
||||
// Strategy: Get the collection directly and check ownership/membership from the collection object
|
||||
collection, err := impl.Get(ctx, collectionID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get collection: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return false, nil // Collection doesn't exist
|
||||
}
|
||||
|
||||
// Check if user is the owner
|
||||
if collection.OwnerID == userID {
|
||||
return true, nil // Owners have all permissions
|
||||
}
|
||||
|
||||
// Check if user is a member with sufficient permissions
|
||||
for _, member := range collection.Members {
|
||||
if member.RecipientID == userID {
|
||||
return impl.hasPermission(member.PermissionLevel, requiredPermission), nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil // User has no access
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/collectionsync.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
// GetCollectionSyncData uses the general table when you need all collections regardless of access type
|
||||
func (impl *collectionRepositoryImpl) GetCollectionSyncData(ctx context.Context, userID gocql.UUID, cursor *dom_collection.CollectionSyncCursor, limit int64) (*dom_collection.CollectionSyncResponse, error) {
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
// Key Insight: We can query all collections for a user efficiently because user_id is the partition key
|
||||
// We select access_type in the result set so we can filter or categorize after retrieval
|
||||
if cursor == nil {
|
||||
query = `SELECT collection_id, modified_at, access_type FROM
|
||||
collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? LIMIT ?`
|
||||
args = []any{userID, limit}
|
||||
} else {
|
||||
query = `SELECT collection_id, modified_at, access_type FROM
|
||||
collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND (modified_at, collection_id) > (?, ?) LIMIT ?`
|
||||
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
|
||||
}
|
||||
|
||||
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
|
||||
|
||||
var syncItems []dom_collection.CollectionSyncItem
|
||||
var lastModified time.Time
|
||||
var lastID gocql.UUID
|
||||
|
||||
// Critical Fix: We must scan all three selected columns
|
||||
var collectionID gocql.UUID
|
||||
var modifiedAt time.Time
|
||||
var accessType string
|
||||
|
||||
for iter.Scan(&collectionID, &modifiedAt, &accessType) {
|
||||
// Get minimal sync data for this collection
|
||||
syncItem, err := impl.getCollectionSyncItem(ctx, collectionID)
|
||||
if err != nil {
|
||||
impl.Logger.Warn("failed to get sync item for collection",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if syncItem != nil {
|
||||
syncItems = append(syncItems, *syncItem)
|
||||
lastModified = modifiedAt
|
||||
lastID = collectionID
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection sync data: %w", err)
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
response := &dom_collection.CollectionSyncResponse{
|
||||
Collections: syncItems,
|
||||
HasMore: len(syncItems) == int(limit),
|
||||
}
|
||||
|
||||
// Set next cursor if there are more results
|
||||
if response.HasMore {
|
||||
response.NextCursor = &dom_collection.CollectionSyncCursor{
|
||||
LastModified: lastModified,
|
||||
LastID: lastID,
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetCollectionSyncData uses the access-type-specific table for optimal performance
|
||||
// This method demonstrates the power of compound partition keys in Cassandra
|
||||
func (impl *collectionRepositoryImpl) GetCollectionSyncDataByAccessType(ctx context.Context, userID gocql.UUID, cursor *dom_collection.CollectionSyncCursor, limit int64, accessType string) (*dom_collection.CollectionSyncResponse, error) {
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
// Key Insight: With the compound partition key (user_id, access_type), this query is lightning fast
|
||||
// Cassandra can directly access the specific partition without any filtering or scanning
|
||||
if cursor == nil {
|
||||
query = `SELECT collection_id, modified_at FROM
|
||||
collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner' LIMIT ?`
|
||||
args = []any{userID, limit}
|
||||
} else {
|
||||
query = `SELECT collection_id, modified_at FROM
|
||||
collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner' AND (modified_at, collection_id) > (?, ?) LIMIT ?`
|
||||
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
|
||||
}
|
||||
|
||||
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
|
||||
|
||||
var syncItems []dom_collection.CollectionSyncItem
|
||||
var lastModified time.Time
|
||||
var lastID gocql.UUID
|
||||
|
||||
var collectionID gocql.UUID
|
||||
var modifiedAt time.Time
|
||||
|
||||
for iter.Scan(&collectionID, &modifiedAt) {
|
||||
// Get minimal sync data for this collection
|
||||
syncItem, err := impl.getCollectionSyncItem(ctx, collectionID)
|
||||
if err != nil {
|
||||
impl.Logger.Warn("failed to get sync item for collection",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if syncItem != nil {
|
||||
syncItems = append(syncItems, *syncItem)
|
||||
lastModified = modifiedAt
|
||||
lastID = collectionID
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection sync data: %w", err)
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
response := &dom_collection.CollectionSyncResponse{
|
||||
Collections: syncItems,
|
||||
HasMore: len(syncItems) == int(limit),
|
||||
}
|
||||
|
||||
// Set next cursor if there are more results
|
||||
if response.HasMore {
|
||||
response.NextCursor = &dom_collection.CollectionSyncCursor{
|
||||
LastModified: lastModified,
|
||||
LastID: lastID,
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Helper method to get minimal sync data for a collection
|
||||
func (impl *collectionRepositoryImpl) getCollectionSyncItem(ctx context.Context, collectionID gocql.UUID) (*dom_collection.CollectionSyncItem, error) {
|
||||
var (
|
||||
id gocql.UUID
|
||||
version, tombstoneVersion uint64
|
||||
modifiedAt, tombstoneExpiry time.Time
|
||||
state string
|
||||
parentID gocql.UUID
|
||||
encryptedCustomIcon string
|
||||
)
|
||||
|
||||
query := `SELECT id, version, modified_at, state, parent_id, tombstone_version, tombstone_expiry, encrypted_custom_icon
|
||||
FROM collections_by_id WHERE id = ?`
|
||||
|
||||
err := impl.Session.Query(query, collectionID).WithContext(ctx).Scan(
|
||||
&id, &version, &modifiedAt, &state, &parentID, &tombstoneVersion, &tombstoneExpiry, &encryptedCustomIcon)
|
||||
|
||||
if err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get collection sync item: %w", err)
|
||||
}
|
||||
|
||||
syncItem := &dom_collection.CollectionSyncItem{
|
||||
ID: id,
|
||||
Version: version,
|
||||
ModifiedAt: modifiedAt,
|
||||
State: state,
|
||||
TombstoneVersion: tombstoneVersion,
|
||||
TombstoneExpiry: tombstoneExpiry,
|
||||
EncryptedCustomIcon: encryptedCustomIcon,
|
||||
}
|
||||
|
||||
// Only include ParentID if it's valid
|
||||
if impl.isValidUUID(parentID) {
|
||||
syncItem.ParentID = &parentID
|
||||
}
|
||||
|
||||
return syncItem, nil
|
||||
}
|
||||
334
cloud/maplefile-backend/internal/repo/collection/count.go
Normal file
334
cloud/maplefile-backend/internal/repo/collection/count.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/count.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
// CountOwnedCollections counts all collections (folders + albums) owned by the user
|
||||
func (impl *collectionRepositoryImpl) CountOwnedCollections(ctx context.Context, userID gocql.UUID) (int, error) {
|
||||
return impl.countCollectionsByUserAndType(ctx, userID, "owner", "")
|
||||
}
|
||||
|
||||
// CountSharedCollections counts all collections (folders + albums) shared with the user
|
||||
func (impl *collectionRepositoryImpl) CountSharedCollections(ctx context.Context, userID gocql.UUID) (int, error) {
|
||||
return impl.countCollectionsByUserAndType(ctx, userID, "member", "")
|
||||
}
|
||||
|
||||
// CountOwnedFolders counts only folders owned by the user
|
||||
func (impl *collectionRepositoryImpl) CountOwnedFolders(ctx context.Context, userID gocql.UUID) (int, error) {
|
||||
return impl.countCollectionsByUserAndType(ctx, userID, "owner", dom_collection.CollectionTypeFolder)
|
||||
}
|
||||
|
||||
// CountSharedFolders counts only folders shared with the user
|
||||
func (impl *collectionRepositoryImpl) CountSharedFolders(ctx context.Context, userID gocql.UUID) (int, error) {
|
||||
return impl.countCollectionsByUserAndType(ctx, userID, "member", dom_collection.CollectionTypeFolder)
|
||||
}
|
||||
|
||||
// countCollectionsByUserAndType is a helper method that efficiently counts collections
|
||||
// filterType: empty string for all types, or specific type like "folder"
|
||||
func (impl *collectionRepositoryImpl) countCollectionsByUserAndType(ctx context.Context, userID gocql.UUID, accessType, filterType string) (int, error) {
|
||||
// Use the access-type-specific table for efficient querying
|
||||
query := `SELECT collection_id FROM maplefile.collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = ?`
|
||||
|
||||
impl.Logger.Debug("Starting collection count query",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.String("filter_type", filterType))
|
||||
|
||||
iter := impl.Session.Query(query, userID, accessType).WithContext(ctx).Iter()
|
||||
|
||||
count := 0
|
||||
totalScanned := 0
|
||||
var collectionID gocql.UUID
|
||||
var debugCollectionIDs []string
|
||||
|
||||
// Iterate through results and count based on criteria
|
||||
for iter.Scan(&collectionID) {
|
||||
totalScanned++
|
||||
debugCollectionIDs = append(debugCollectionIDs, collectionID.String())
|
||||
|
||||
impl.Logger.Debug("Processing collection for count",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Int("total_scanned", totalScanned),
|
||||
zap.String("access_type", accessType))
|
||||
|
||||
// Get the collection to check state and type
|
||||
collection, err := impl.getBaseCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
impl.Logger.Warn("failed to get collection for counting",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
impl.Logger.Warn("collection not found for counting",
|
||||
zap.String("collection_id", collectionID.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
impl.Logger.Debug("Collection details for counting",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("state", collection.State),
|
||||
zap.String("collection_type", collection.CollectionType),
|
||||
zap.String("owner_id", collection.OwnerID.String()),
|
||||
zap.String("querying_user_id", userID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.String("required_filter_type", filterType))
|
||||
|
||||
// Only count active collections
|
||||
if collection.State != dom_collection.CollectionStateActive {
|
||||
impl.Logger.Debug("Skipping collection due to non-active state",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("state", collection.State))
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by type if specified
|
||||
if filterType != "" && collection.CollectionType != filterType {
|
||||
impl.Logger.Debug("Skipping collection due to type filter",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("collection_type", collection.CollectionType),
|
||||
zap.String("required_type", filterType))
|
||||
continue
|
||||
}
|
||||
|
||||
count++
|
||||
impl.Logger.Info("Collection counted",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.String("owner_id", collection.OwnerID.String()),
|
||||
zap.String("querying_user_id", userID.String()),
|
||||
zap.Bool("is_owner", collection.OwnerID == userID),
|
||||
zap.Int("current_count", count))
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("failed to count collections",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.String("filter_type", filterType),
|
||||
zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to count collections: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("Collection count completed",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.String("filter_type", filterType),
|
||||
zap.Int("final_count", count),
|
||||
zap.Int("total_scanned", totalScanned),
|
||||
zap.Strings("scanned_collection_ids", debugCollectionIDs))
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// FIXED DEBUG: Query both access types separately to avoid ALLOW FILTERING
|
||||
func (impl *collectionRepositoryImpl) DebugCollectionRecords(ctx context.Context, userID gocql.UUID) error {
|
||||
impl.Logger.Info("=== DEBUG: Checking OWNER records ===")
|
||||
|
||||
// Check owner records
|
||||
ownerQuery := `SELECT user_id, access_type, modified_at, collection_id, permission_level, state
|
||||
FROM maplefile.collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = ?`
|
||||
|
||||
ownerIter := impl.Session.Query(ownerQuery, userID, "owner").WithContext(ctx).Iter()
|
||||
|
||||
var (
|
||||
resultUserID gocql.UUID
|
||||
accessType string
|
||||
modifiedAt time.Time
|
||||
collectionID gocql.UUID
|
||||
permissionLevel string
|
||||
state string
|
||||
)
|
||||
|
||||
ownerCount := 0
|
||||
for ownerIter.Scan(&resultUserID, &accessType, &modifiedAt, &collectionID, &permissionLevel, &state) {
|
||||
ownerCount++
|
||||
impl.Logger.Info("DEBUG: Found OWNER record",
|
||||
zap.Int("record_number", ownerCount),
|
||||
zap.String("user_id", resultUserID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.Time("modified_at", modifiedAt),
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("permission_level", permissionLevel),
|
||||
zap.String("state", state))
|
||||
}
|
||||
ownerIter.Close()
|
||||
|
||||
impl.Logger.Info("=== DEBUG: Checking MEMBER records ===")
|
||||
|
||||
// Check member records
|
||||
memberIter := impl.Session.Query(ownerQuery, userID, "member").WithContext(ctx).Iter()
|
||||
|
||||
memberCount := 0
|
||||
for memberIter.Scan(&resultUserID, &accessType, &modifiedAt, &collectionID, &permissionLevel, &state) {
|
||||
memberCount++
|
||||
impl.Logger.Info("DEBUG: Found MEMBER record",
|
||||
zap.Int("record_number", memberCount),
|
||||
zap.String("user_id", resultUserID.String()),
|
||||
zap.String("access_type", accessType),
|
||||
zap.Time("modified_at", modifiedAt),
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("permission_level", permissionLevel),
|
||||
zap.String("state", state))
|
||||
}
|
||||
memberIter.Close()
|
||||
|
||||
impl.Logger.Info("DEBUG: Total records summary",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("owner_records", ownerCount),
|
||||
zap.Int("member_records", memberCount),
|
||||
zap.Int("total_records", ownerCount+memberCount))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Alternative optimized implementation for when you need both owned and shared counts
|
||||
// This reduces database round trips by querying once and separating in memory
|
||||
func (impl *collectionRepositoryImpl) countCollectionsSummary(ctx context.Context, userID gocql.UUID, filterType string) (ownedCount, sharedCount int, err error) {
|
||||
// Query all collections for the user using the general table
|
||||
query := `SELECT collection_id, access_type FROM maplefile.collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
var accessType string
|
||||
|
||||
for iter.Scan(&collectionID, &accessType) {
|
||||
// Get the collection to check state and type
|
||||
collection, getErr := impl.getBaseCollection(ctx, collectionID)
|
||||
if getErr != nil {
|
||||
impl.Logger.Warn("failed to get collection for counting summary",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(getErr))
|
||||
continue
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only count active collections
|
||||
if collection.State != dom_collection.CollectionStateActive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by type if specified
|
||||
if filterType != "" && collection.CollectionType != filterType {
|
||||
continue
|
||||
}
|
||||
|
||||
// Count based on access type
|
||||
switch accessType {
|
||||
case "owner":
|
||||
ownedCount++
|
||||
case "member":
|
||||
sharedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if err = iter.Close(); err != nil {
|
||||
impl.Logger.Error("failed to count collections summary",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("filter_type", filterType),
|
||||
zap.Error(err))
|
||||
return 0, 0, fmt.Errorf("failed to count collections summary: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Debug("counted collections summary successfully",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("filter_type", filterType),
|
||||
zap.Int("owned_count", ownedCount),
|
||||
zap.Int("shared_count", sharedCount))
|
||||
|
||||
return ownedCount, sharedCount, nil
|
||||
}
|
||||
|
||||
// CountTotalUniqueFolders counts unique folders accessible to the user (deduplicates owned+shared)
|
||||
func (impl *collectionRepositoryImpl) CountTotalUniqueFolders(ctx context.Context, userID gocql.UUID) (int, error) {
|
||||
// Use a set to track unique collection IDs to avoid double-counting
|
||||
uniqueCollectionIDs := make(map[gocql.UUID]bool)
|
||||
|
||||
// Query all collections for the user using the general table
|
||||
query := `SELECT collection_id FROM maplefile.collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
totalScanned := 0
|
||||
|
||||
for iter.Scan(&collectionID) {
|
||||
totalScanned++
|
||||
|
||||
// Get the collection to check state and type
|
||||
collection, err := impl.getBaseCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
impl.Logger.Warn("failed to get collection for unique counting",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
impl.Logger.Debug("Processing collection for unique count",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("state", collection.State),
|
||||
zap.String("collection_type", collection.CollectionType),
|
||||
zap.Int("total_scanned", totalScanned))
|
||||
|
||||
// Only count active folders
|
||||
if collection.State != dom_collection.CollectionStateActive {
|
||||
impl.Logger.Debug("Skipping collection due to non-active state",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("state", collection.State))
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by folder type
|
||||
if collection.CollectionType != dom_collection.CollectionTypeFolder {
|
||||
impl.Logger.Debug("Skipping collection due to type filter",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("collection_type", collection.CollectionType))
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to unique set (automatically deduplicates)
|
||||
uniqueCollectionIDs[collectionID] = true
|
||||
|
||||
impl.Logger.Debug("Added unique folder to count",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Int("current_unique_count", len(uniqueCollectionIDs)))
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("failed to count unique folders",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to count unique folders: %w", err)
|
||||
}
|
||||
|
||||
uniqueCount := len(uniqueCollectionIDs)
|
||||
|
||||
impl.Logger.Info("Unique folder count completed",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("total_scanned", totalScanned),
|
||||
zap.Int("unique_folders", uniqueCount))
|
||||
|
||||
return uniqueCount, nil
|
||||
}
|
||||
214
cloud/maplefile-backend/internal/repo/collection/create.go
Normal file
214
cloud/maplefile-backend/internal/repo/collection/create.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/create.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) Create(ctx context.Context, collection *dom_collection.Collection) error {
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection cannot be nil")
|
||||
}
|
||||
|
||||
if !impl.isValidUUID(collection.ID) {
|
||||
return fmt.Errorf("collection ID is required")
|
||||
}
|
||||
|
||||
if !impl.isValidUUID(collection.OwnerID) {
|
||||
return fmt.Errorf("owner ID is required")
|
||||
}
|
||||
|
||||
// Set creation timestamp if not set
|
||||
if collection.CreatedAt.IsZero() {
|
||||
collection.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
if collection.ModifiedAt.IsZero() {
|
||||
collection.ModifiedAt = collection.CreatedAt
|
||||
}
|
||||
|
||||
// Ensure state is set
|
||||
if collection.State == "" {
|
||||
collection.State = dom_collection.CollectionStateActive
|
||||
}
|
||||
|
||||
// Serialize complex fields
|
||||
ancestorIDsJSON, err := impl.serializeAncestorIDs(collection.AncestorIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize ancestor IDs: %w", err)
|
||||
}
|
||||
|
||||
encryptedKeyJSON, err := impl.serializeEncryptedCollectionKey(collection.EncryptedCollectionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize encrypted collection key: %w", err)
|
||||
}
|
||||
|
||||
tagsJSON, err := impl.serializeTags(collection.Tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize tags: %w", err)
|
||||
}
|
||||
|
||||
batch := impl.Session.NewBatch(gocql.LoggedBatch)
|
||||
|
||||
// 1. Insert into main table
|
||||
batch.Query(`INSERT INTO collections_by_id
|
||||
(id, owner_id, encrypted_name, collection_type, encrypted_collection_key,
|
||||
encrypted_custom_icon, parent_id, ancestor_ids, file_count, tags, created_at, created_by_user_id,
|
||||
modified_at, modified_by_user_id, version, state, tombstone_version, tombstone_expiry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
collection.ID, collection.OwnerID, collection.EncryptedName, collection.CollectionType,
|
||||
encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON, int64(0), // file_count starts at 0
|
||||
tagsJSON, collection.CreatedAt, collection.CreatedByUserID, collection.ModifiedAt,
|
||||
collection.ModifiedByUserID, collection.Version, collection.State,
|
||||
collection.TombstoneVersion, collection.TombstoneExpiry)
|
||||
|
||||
// 2. Insert owner access into BOTH user access tables
|
||||
|
||||
// 2 -> (1 of 2): Original table: supports queries across all access types
|
||||
batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, modified_at, collection_id, access_type, permission_level, state)
|
||||
VALUES (?, ?, ?, 'owner', ?, ?)`,
|
||||
collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State)
|
||||
|
||||
// 2 -> (2 of 2): Access-type-specific table for efficient filtering
|
||||
batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, access_type, modified_at, collection_id, permission_level, state)
|
||||
VALUES (?, 'owner', ?, ?, ?, ?)`,
|
||||
collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State)
|
||||
|
||||
// 3. Insert into original parent index (still needed for cross-owner parent-child queries)
|
||||
parentID := collection.ParentID
|
||||
if !impl.isValidUUID(parentID) {
|
||||
parentID = impl.nullParentUUID() // Use null UUID for root collections
|
||||
}
|
||||
|
||||
batch.Query(`INSERT INTO collections_by_parent_id_with_asc_created_at_and_asc_collection_id
|
||||
(parent_id, created_at, collection_id, owner_id, state)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
parentID, collection.CreatedAt, collection.ID, collection.OwnerID, collection.State)
|
||||
|
||||
// 4. Insert into composite partition key table for optimized root collection queries
|
||||
batch.Query(`INSERT INTO collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id
|
||||
(parent_id, owner_id, created_at, collection_id, state)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
parentID, collection.OwnerID, collection.CreatedAt, collection.ID, collection.State)
|
||||
|
||||
// 5. Insert into ancestor hierarchy table
|
||||
ancestorEntries := impl.buildAncestorDepthEntries(collection.ID, collection.AncestorIDs)
|
||||
for _, entry := range ancestorEntries {
|
||||
batch.Query(`INSERT INTO collections_by_ancestor_id_with_asc_depth_and_asc_collection_id
|
||||
(ancestor_id, depth, collection_id, state)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
entry.AncestorID, entry.Depth, entry.CollectionID, collection.State)
|
||||
}
|
||||
|
||||
// 6. Insert into denormalized collections_by_tag_id table for each tag
|
||||
for _, tag := range collection.Tags {
|
||||
batch.Query(`INSERT INTO collections_by_tag_id
|
||||
(tag_id, collection_id, owner_id, encrypted_name, collection_type,
|
||||
encrypted_collection_key, encrypted_custom_icon, parent_id, ancestor_ids,
|
||||
file_count, tags, created_at, created_by_user_id, modified_at, modified_by_user_id,
|
||||
version, state, tombstone_version, tombstone_expiry,
|
||||
created_from_ip_address, modified_from_ip_address, ip_anonymized_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tag.ID, collection.ID, collection.OwnerID, collection.EncryptedName, collection.CollectionType,
|
||||
encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON,
|
||||
collection.FileCount, tagsJSON, collection.CreatedAt, collection.CreatedByUserID,
|
||||
collection.ModifiedAt, collection.ModifiedByUserID, collection.Version, collection.State,
|
||||
collection.TombstoneVersion, collection.TombstoneExpiry,
|
||||
nil, nil, nil) // IP tracking fields not yet in domain model
|
||||
}
|
||||
|
||||
// 7. Insert members into normalized table AND both user access tables - WITH CONSISTENT VALIDATION
|
||||
for i, member := range collection.Members {
|
||||
impl.Logger.Info("processing member for creation",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("member_index", i),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("permission_level", member.PermissionLevel),
|
||||
zap.Bool("is_inherited", member.IsInherited))
|
||||
|
||||
// Validate member data before insertion - CONSISTENT WITH UPDATE METHOD
|
||||
if !impl.isValidUUID(member.RecipientID) {
|
||||
return fmt.Errorf("invalid recipient ID for member %d", i)
|
||||
}
|
||||
if member.RecipientEmail == "" {
|
||||
return fmt.Errorf("recipient email is required for member %d", i)
|
||||
}
|
||||
if member.PermissionLevel == "" {
|
||||
return fmt.Errorf("permission level is required for member %d", i)
|
||||
}
|
||||
|
||||
// FIXED: Only require encrypted collection key for non-owner members
|
||||
// The owner has access to the collection key through their master key
|
||||
isOwner := member.RecipientID == collection.OwnerID
|
||||
if !isOwner && len(member.EncryptedCollectionKey) == 0 {
|
||||
impl.Logger.Error("CRITICAL: encrypted collection key missing for shared member during creation",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("member_index", i),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("owner_id", collection.OwnerID.String()),
|
||||
zap.Bool("is_owner", isOwner),
|
||||
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
|
||||
return fmt.Errorf("VALIDATION ERROR: encrypted collection key is required for shared member %d (recipient: %s, email: %s). This indicates a frontend bug or API misuse.", i, member.RecipientID.String(), validation.MaskEmail(member.RecipientEmail))
|
||||
}
|
||||
|
||||
// Ensure member has an ID - CRITICAL: Set this before insertion
|
||||
if !impl.isValidUUID(member.ID) {
|
||||
member.ID = gocql.TimeUUID()
|
||||
collection.Members[i].ID = member.ID // Update the collection's member slice
|
||||
impl.Logger.Debug("generated member ID during creation",
|
||||
zap.String("member_id", member.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()))
|
||||
}
|
||||
|
||||
// Insert into normalized members table
|
||||
batch.Query(`INSERT INTO collection_members_by_collection_id_and_recipient_id
|
||||
(collection_id, recipient_id, member_id, recipient_email, granted_by_id,
|
||||
encrypted_collection_key, permission_level, created_at,
|
||||
is_inherited, inherited_from_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
collection.ID, member.RecipientID, member.ID, member.RecipientEmail,
|
||||
member.GrantedByID, member.EncryptedCollectionKey,
|
||||
member.PermissionLevel, member.CreatedAt,
|
||||
member.IsInherited, member.InheritedFromID)
|
||||
|
||||
// Add member access to BOTH user access tables
|
||||
// Original table: supports all-access-types queries
|
||||
batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, modified_at, collection_id, access_type, permission_level, state)
|
||||
VALUES (?, ?, ?, 'member', ?, ?)`,
|
||||
member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State)
|
||||
|
||||
// NEW: Access-type-specific table for efficient member queries
|
||||
batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, access_type, modified_at, collection_id, permission_level, state)
|
||||
VALUES (?, 'member', ?, ?, ?, ?)`,
|
||||
member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State)
|
||||
}
|
||||
|
||||
// Execute batch - this ensures all tables are updated atomically
|
||||
if err := impl.Session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
|
||||
impl.Logger.Error("failed to create collection",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to create collection: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("collection created successfully in all tables",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("owner_id", collection.OwnerID.String()),
|
||||
zap.Int("member_count", len(collection.Members)))
|
||||
|
||||
return nil
|
||||
}
|
||||
128
cloud/maplefile-backend/internal/repo/collection/delete.go
Normal file
128
cloud/maplefile-backend/internal/repo/collection/delete.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/delete.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) SoftDelete(ctx context.Context, id gocql.UUID) error {
|
||||
collection, err := impl.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection for soft delete: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
// Validate state transition
|
||||
if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateDeleted); err != nil {
|
||||
return fmt.Errorf("invalid state transition: %w", err)
|
||||
}
|
||||
|
||||
// Update collection state
|
||||
collection.State = dom_collection.CollectionStateDeleted
|
||||
collection.ModifiedAt = time.Now()
|
||||
collection.Version++
|
||||
collection.TombstoneVersion = collection.Version
|
||||
collection.TombstoneExpiry = time.Now().Add(30 * 24 * time.Hour) // 30 days
|
||||
|
||||
// Use the update method to ensure consistency across all tables
|
||||
return impl.Update(ctx, collection)
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) HardDelete(ctx context.Context, id gocql.UUID) error {
|
||||
collection, err := impl.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection for hard delete: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
batch := impl.Session.NewBatch(gocql.LoggedBatch)
|
||||
|
||||
// 1. Delete from main table
|
||||
batch.Query(`DELETE FROM collections_by_id WHERE id = ?`, id)
|
||||
|
||||
// 2. Delete from BOTH user access tables (owner entries)
|
||||
// This demonstrates the importance of cleaning up all table views during hard deletes
|
||||
|
||||
// Delete owner from original table
|
||||
batch.Query(`DELETE FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND modified_at = ? AND collection_id = ?`,
|
||||
collection.OwnerID, collection.ModifiedAt, id)
|
||||
|
||||
// Delete owner from access-type-specific table
|
||||
batch.Query(`DELETE FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner' AND modified_at = ? AND collection_id = ?`,
|
||||
collection.OwnerID, collection.ModifiedAt, id)
|
||||
|
||||
// 3. Delete member access entries from BOTH user access tables
|
||||
for _, member := range collection.Members {
|
||||
// Delete from original table
|
||||
batch.Query(`DELETE FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND modified_at = ? AND collection_id = ?`,
|
||||
member.RecipientID, collection.ModifiedAt, id)
|
||||
|
||||
// Delete from access-type-specific table
|
||||
batch.Query(`DELETE FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'member' AND modified_at = ? AND collection_id = ?`,
|
||||
member.RecipientID, collection.ModifiedAt, id)
|
||||
}
|
||||
|
||||
// 4. Delete from original parent index
|
||||
parentID := collection.ParentID
|
||||
if !impl.isValidUUID(parentID) {
|
||||
parentID = impl.nullParentUUID()
|
||||
}
|
||||
batch.Query(`DELETE FROM collections_by_parent_id_with_asc_created_at_and_asc_collection_id
|
||||
WHERE parent_id = ? AND created_at = ? AND collection_id = ?`,
|
||||
parentID, collection.CreatedAt, id)
|
||||
|
||||
// 5. Delete from composite partition key table
|
||||
batch.Query(`DELETE FROM collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id
|
||||
WHERE parent_id = ? AND owner_id = ? AND created_at = ? AND collection_id = ?`,
|
||||
parentID, collection.OwnerID, collection.CreatedAt, id)
|
||||
|
||||
// 6. Delete from ancestor hierarchy
|
||||
ancestorEntries := impl.buildAncestorDepthEntries(id, collection.AncestorIDs)
|
||||
for _, entry := range ancestorEntries {
|
||||
batch.Query(`DELETE FROM collections_by_ancestor_id_with_asc_depth_and_asc_collection_id
|
||||
WHERE ancestor_id = ? AND depth = ? AND collection_id = ?`,
|
||||
entry.AncestorID, entry.Depth, entry.CollectionID)
|
||||
}
|
||||
|
||||
// 7. Delete from members table
|
||||
batch.Query(`DELETE FROM collection_members_by_collection_id_and_recipient_id WHERE collection_id = ?`, id)
|
||||
|
||||
// 8. Delete from denormalized collections_by_tag_id table for all tags
|
||||
for _, tag := range collection.Tags {
|
||||
batch.Query(`DELETE FROM collections_by_tag_id
|
||||
WHERE tag_id = ? AND collection_id = ?`,
|
||||
tag.ID, id)
|
||||
}
|
||||
|
||||
// Execute batch - ensures atomic deletion across all tables
|
||||
if err := impl.Session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
|
||||
impl.Logger.Error("failed to hard delete collection from all tables",
|
||||
zap.String("collection_id", id.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to hard delete collection: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("collection hard deleted successfully from all tables",
|
||||
zap.String("collection_id", id.String()),
|
||||
zap.String("owner_id", collection.OwnerID.String()),
|
||||
zap.Int("member_count", len(collection.Members)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// IncrementFileCount increments the file count for a collection
|
||||
func (impl *collectionRepositoryImpl) IncrementFileCount(ctx context.Context, collectionID gocql.UUID) error {
|
||||
// Read current file count
|
||||
var currentCount int64
|
||||
readQuery := `SELECT file_count FROM maplefile.collections_by_id WHERE id = ?`
|
||||
if err := impl.Session.Query(readQuery, collectionID).WithContext(ctx).Scan(¤tCount); err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
impl.Logger.Warn("collection not found for file count increment",
|
||||
zap.String("collection_id", collectionID.String()))
|
||||
return nil // Collection doesn't exist, nothing to increment
|
||||
}
|
||||
impl.Logger.Error("failed to read file count for increment",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to read file count: %w", err)
|
||||
}
|
||||
|
||||
// Write incremented count
|
||||
newCount := currentCount + 1
|
||||
updateQuery := `UPDATE maplefile.collections_by_id SET file_count = ? WHERE id = ?`
|
||||
if err := impl.Session.Query(updateQuery, newCount, collectionID).WithContext(ctx).Exec(); err != nil {
|
||||
impl.Logger.Error("failed to increment file count",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to increment file count: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Debug("incremented file count",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Int64("old_count", currentCount),
|
||||
zap.Int64("new_count", newCount))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecrementFileCount decrements the file count for a collection
|
||||
func (impl *collectionRepositoryImpl) DecrementFileCount(ctx context.Context, collectionID gocql.UUID) error {
|
||||
// Read current file count
|
||||
var currentCount int64
|
||||
readQuery := `SELECT file_count FROM maplefile.collections_by_id WHERE id = ?`
|
||||
if err := impl.Session.Query(readQuery, collectionID).WithContext(ctx).Scan(¤tCount); err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
impl.Logger.Warn("collection not found for file count decrement",
|
||||
zap.String("collection_id", collectionID.String()))
|
||||
return nil // Collection doesn't exist, nothing to decrement
|
||||
}
|
||||
impl.Logger.Error("failed to read file count for decrement",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to read file count: %w", err)
|
||||
}
|
||||
|
||||
// Write decremented count (don't go below 0)
|
||||
newCount := currentCount - 1
|
||||
if newCount < 0 {
|
||||
newCount = 0
|
||||
}
|
||||
updateQuery := `UPDATE maplefile.collections_by_id SET file_count = ? WHERE id = ?`
|
||||
if err := impl.Session.Query(updateQuery, newCount, collectionID).WithContext(ctx).Exec(); err != nil {
|
||||
impl.Logger.Error("failed to decrement file count",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to decrement file count: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Debug("decremented file count",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Int64("old_count", currentCount),
|
||||
zap.Int64("new_count", newCount))
|
||||
|
||||
return nil
|
||||
}
|
||||
482
cloud/maplefile-backend/internal/repo/collection/get.go
Normal file
482
cloud/maplefile-backend/internal/repo/collection/get.go
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/get.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Core helper methods for loading collections with members
|
||||
func (impl *collectionRepositoryImpl) loadCollectionWithMembers(ctx context.Context, collectionID gocql.UUID) (*dom_collection.Collection, error) {
|
||||
// 1. Load base collection
|
||||
collection, err := impl.getBaseCollection(ctx, collectionID)
|
||||
if err != nil || collection == nil {
|
||||
return collection, err
|
||||
}
|
||||
|
||||
// 2. Load and populate members
|
||||
members, err := impl.getCollectionMembers(ctx, collectionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collection.Members = members
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) getBaseCollection(ctx context.Context, id gocql.UUID) (*dom_collection.Collection, error) {
|
||||
var (
|
||||
encryptedName, collectionType, encryptedKeyJSON string
|
||||
encryptedCustomIcon string
|
||||
ancestorIDsJSON string
|
||||
tagsJSON string
|
||||
parentID, ownerID, createdByUserID, modifiedByUserID gocql.UUID
|
||||
createdAt, modifiedAt, tombstoneExpiry time.Time
|
||||
version, tombstoneVersion uint64
|
||||
state string
|
||||
fileCount int64
|
||||
)
|
||||
|
||||
query := `SELECT id, owner_id, encrypted_name, collection_type, encrypted_collection_key,
|
||||
encrypted_custom_icon, parent_id, ancestor_ids, file_count, tags, created_at, created_by_user_id, modified_at,
|
||||
modified_by_user_id, version, state, tombstone_version, tombstone_expiry
|
||||
FROM collections_by_id WHERE id = ?`
|
||||
|
||||
err := impl.Session.Query(query, id).WithContext(ctx).Scan(
|
||||
&id, &ownerID, &encryptedName, &collectionType, &encryptedKeyJSON,
|
||||
&encryptedCustomIcon, &parentID, &ancestorIDsJSON, &fileCount, &tagsJSON, &createdAt, &createdByUserID,
|
||||
&modifiedAt, &modifiedByUserID, &version, &state, &tombstoneVersion, &tombstoneExpiry)
|
||||
|
||||
if err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get collection: %w", err)
|
||||
}
|
||||
|
||||
// Deserialize complex fields
|
||||
ancestorIDs, err := impl.deserializeAncestorIDs(ancestorIDsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize ancestor IDs: %w", err)
|
||||
}
|
||||
|
||||
encryptedKey, err := impl.deserializeEncryptedCollectionKey(encryptedKeyJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize encrypted collection key: %w", err)
|
||||
}
|
||||
|
||||
tags, err := impl.deserializeTags(tagsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize tags: %w", err)
|
||||
}
|
||||
|
||||
collection := &dom_collection.Collection{
|
||||
ID: id,
|
||||
OwnerID: ownerID,
|
||||
EncryptedName: encryptedName,
|
||||
CollectionType: collectionType,
|
||||
EncryptedCollectionKey: encryptedKey,
|
||||
EncryptedCustomIcon: encryptedCustomIcon,
|
||||
Members: []dom_collection.CollectionMembership{}, // Will be populated separately
|
||||
ParentID: parentID,
|
||||
AncestorIDs: ancestorIDs,
|
||||
FileCount: fileCount,
|
||||
Tags: tags,
|
||||
CreatedAt: createdAt,
|
||||
CreatedByUserID: createdByUserID,
|
||||
ModifiedAt: modifiedAt,
|
||||
ModifiedByUserID: modifiedByUserID,
|
||||
Version: version,
|
||||
State: state,
|
||||
TombstoneVersion: tombstoneVersion,
|
||||
TombstoneExpiry: tombstoneExpiry,
|
||||
}
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) getCollectionMembers(ctx context.Context, collectionID gocql.UUID) ([]dom_collection.CollectionMembership, error) {
|
||||
var members []dom_collection.CollectionMembership
|
||||
|
||||
query := `SELECT recipient_id, member_id, recipient_email, granted_by_id,
|
||||
encrypted_collection_key, permission_level, created_at,
|
||||
is_inherited, inherited_from_id
|
||||
FROM collection_members_by_collection_id_and_recipient_id WHERE collection_id = ?`
|
||||
|
||||
impl.Logger.Info("🔍 GET MEMBERS: Querying collection members",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("query", query))
|
||||
|
||||
iter := impl.Session.Query(query, collectionID).WithContext(ctx).Iter()
|
||||
|
||||
var (
|
||||
recipientID, memberID, grantedByID, inheritedFromID gocql.UUID
|
||||
recipientEmail, permissionLevel string
|
||||
encryptedCollectionKey []byte
|
||||
createdAt time.Time
|
||||
isInherited bool
|
||||
)
|
||||
|
||||
for iter.Scan(&recipientID, &memberID, &recipientEmail, &grantedByID,
|
||||
&encryptedCollectionKey, &permissionLevel, &createdAt,
|
||||
&isInherited, &inheritedFromID) {
|
||||
|
||||
impl.Logger.Info("🔍 GET MEMBERS: Found member",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(recipientEmail)),
|
||||
zap.String("recipient_id", recipientID.String()),
|
||||
zap.Int("encrypted_key_length", len(encryptedCollectionKey)),
|
||||
zap.String("permission_level", permissionLevel))
|
||||
|
||||
member := dom_collection.CollectionMembership{
|
||||
ID: memberID,
|
||||
CollectionID: collectionID,
|
||||
RecipientID: recipientID,
|
||||
RecipientEmail: recipientEmail,
|
||||
GrantedByID: grantedByID,
|
||||
EncryptedCollectionKey: encryptedCollectionKey,
|
||||
PermissionLevel: permissionLevel,
|
||||
CreatedAt: createdAt,
|
||||
IsInherited: isInherited,
|
||||
InheritedFromID: inheritedFromID,
|
||||
}
|
||||
members = append(members, member)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("🔍 GET MEMBERS: Failed to iterate members",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
impl.Logger.Info("🔍 GET MEMBERS: Query completed",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Int("members_found", len(members)))
|
||||
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) loadMultipleCollectionsWithMembers(ctx context.Context, collectionIDs []gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
if len(collectionIDs) == 0 {
|
||||
return []*dom_collection.Collection{}, nil
|
||||
}
|
||||
|
||||
var collections []*dom_collection.Collection
|
||||
for _, id := range collectionIDs {
|
||||
collection, err := impl.loadCollectionWithMembers(ctx, id)
|
||||
if err != nil {
|
||||
impl.Logger.Warn("failed to load collection",
|
||||
zap.String("collection_id", id.String()),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if collection != nil {
|
||||
collections = append(collections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) Get(ctx context.Context, id gocql.UUID) (*dom_collection.Collection, error) {
|
||||
return impl.loadCollectionWithMembers(ctx, id)
|
||||
}
|
||||
|
||||
// FIXED: Removed state filtering from query, filter in memory instead
|
||||
func (impl *collectionRepositoryImpl) GetAllByUserID(ctx context.Context, ownerID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
var collectionIDs []gocql.UUID
|
||||
|
||||
query := `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner'`
|
||||
|
||||
iter := impl.Session.Query(query, ownerID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("failed to get collections",
|
||||
zap.Any("user_id", ownerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("failed to get collections by owner: %w", err)
|
||||
}
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
impl.Logger.Debug("retrieved owned collections efficiently",
|
||||
zap.String("owner_id", ownerID.String()),
|
||||
zap.Int("total_found", len(allCollections)),
|
||||
zap.Int("active_count", len(activeCollections)))
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
|
||||
// FIXED: Removed state filtering from query, filter in memory instead
|
||||
func (impl *collectionRepositoryImpl) GetCollectionsSharedWithUser(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
impl.Logger.Info("🔍 REPO: Getting collections shared with user",
|
||||
zap.String("user_id", userID.String()))
|
||||
|
||||
var collectionIDs []gocql.UUID
|
||||
|
||||
query := `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'member'`
|
||||
|
||||
impl.Logger.Info("🔍 REPO: Executing query",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("query", query))
|
||||
|
||||
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
impl.Logger.Info("🔍 REPO: Found collection ID in index",
|
||||
zap.String("collection_id", collectionID.String()))
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("🔍 REPO: Query iteration failed",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get shared collections: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("🔍 REPO: Query completed",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("collection_ids_found", len(collectionIDs)))
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
impl.Logger.Error("🔍 REPO: Failed to load collections",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
impl.Logger.Info("🔍 REPO: Loaded collections",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("collections_loaded", len(allCollections)))
|
||||
|
||||
// Filter to only active collections AND collections where the user has actual membership
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
impl.Logger.Info("🔍 REPO: Checking collection state",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("state", collection.State),
|
||||
zap.Bool("is_active", collection.State == dom_collection.CollectionStateActive))
|
||||
|
||||
if collection.State != dom_collection.CollectionStateActive {
|
||||
impl.Logger.Info("🔍 REPO: Skipping inactive collection",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("state", collection.State))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the user has actual membership in this collection
|
||||
// For GetCollectionsSharedWithUser, we MUST have a membership record
|
||||
// This is the source of truth, not the index
|
||||
hasMembership := false
|
||||
for _, member := range collection.Members {
|
||||
if member.RecipientID == userID {
|
||||
hasMembership = true
|
||||
impl.Logger.Info("🔍 REPO: User has membership in collection",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("permission_level", member.PermissionLevel))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasMembership {
|
||||
// No actual membership record found - this is stale index data
|
||||
// Skip this collection regardless of ownership
|
||||
impl.Logger.Warn("🔍 REPO: Skipping collection with no actual membership (stale index)",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Bool("is_owner", collection.OwnerID == userID),
|
||||
zap.Int("members_count", len(collection.Members)))
|
||||
continue
|
||||
}
|
||||
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
|
||||
impl.Logger.Debug("retrieved shared collections efficiently",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("total_found", len(allCollections)),
|
||||
zap.Int("active_count", len(activeCollections)))
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
|
||||
// NEW METHOD: Demonstrates querying across all access types when needed
|
||||
func (impl *collectionRepositoryImpl) GetAllUserCollections(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
var collectionIDs []gocql.UUID
|
||||
|
||||
query := `SELECT collection_id FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, userID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get all user collections: %w", err)
|
||||
}
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
impl.Logger.Debug("retrieved all user collections efficiently",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("total_found", len(allCollections)),
|
||||
zap.Int("active_count", len(activeCollections)))
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
|
||||
// Uses composite partition key table for better performance
|
||||
func (impl *collectionRepositoryImpl) FindByParent(ctx context.Context, parentID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
var collectionIDs []gocql.UUID
|
||||
|
||||
query := `SELECT collection_id FROM collections_by_parent_id_with_asc_created_at_and_asc_collection_id
|
||||
WHERE parent_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, parentID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to find collections by parent: %w", err)
|
||||
}
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
|
||||
// Uses composite partition key for optimal performance
|
||||
func (impl *collectionRepositoryImpl) FindRootCollections(ctx context.Context, ownerID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
var collectionIDs []gocql.UUID
|
||||
|
||||
// Use the composite partition key table for root collections
|
||||
nullParentID := impl.nullParentUUID()
|
||||
|
||||
query := `SELECT collection_id FROM collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id
|
||||
WHERE parent_id = ? AND owner_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, nullParentID, ownerID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to find root collections: %w", err)
|
||||
}
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
|
||||
// No more recursive queries - single efficient query
|
||||
func (impl *collectionRepositoryImpl) FindDescendants(ctx context.Context, collectionID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
var descendantIDs []gocql.UUID
|
||||
|
||||
query := `SELECT collection_id FROM collections_by_ancestor_id_with_asc_depth_and_asc_collection_id
|
||||
WHERE ancestor_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, collectionID).WithContext(ctx).Iter()
|
||||
|
||||
var descendantID gocql.UUID
|
||||
for iter.Scan(&descendantID) {
|
||||
descendantIDs = append(descendantIDs, descendantID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to find descendants: %w", err)
|
||||
}
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, descendantIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
237
cloud/maplefile-backend/internal/repo/collection/get_filtered.go
Normal file
237
cloud/maplefile-backend/internal/repo/collection/get_filtered.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/get_filtered.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) GetCollectionsWithFilter(ctx context.Context, options dom_collection.CollectionFilterOptions) (*dom_collection.CollectionFilterResult, error) {
|
||||
if !options.IsValid() {
|
||||
return nil, fmt.Errorf("invalid filter options: at least one filter must be enabled")
|
||||
}
|
||||
|
||||
result := &dom_collection.CollectionFilterResult{
|
||||
OwnedCollections: []*dom_collection.Collection{},
|
||||
SharedCollections: []*dom_collection.Collection{},
|
||||
TotalCount: 0,
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Get owned collections if requested
|
||||
if options.IncludeOwned {
|
||||
result.OwnedCollections, err = impl.getOwnedCollectionsOptimized(ctx, options.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get owned collections: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get shared collections if requested
|
||||
if options.IncludeShared {
|
||||
result.SharedCollections, err = impl.getSharedCollectionsOptimized(ctx, options.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get shared collections: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
result.TotalCount = len(result.OwnedCollections) + len(result.SharedCollections)
|
||||
|
||||
impl.Logger.Debug("completed filtered collection query",
|
||||
zap.String("user_id", options.UserID.String()),
|
||||
zap.Bool("include_owned", options.IncludeOwned),
|
||||
zap.Bool("include_shared", options.IncludeShared),
|
||||
zap.Int("owned_count", len(result.OwnedCollections)),
|
||||
zap.Int("shared_count", len(result.SharedCollections)),
|
||||
zap.Int("total_count", result.TotalCount))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Uses the access-type-specific table for maximum efficiency
|
||||
func (impl *collectionRepositoryImpl) getOwnedCollectionsOptimized(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
return impl.GetAllByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// Uses the access-type-specific table
|
||||
func (impl *collectionRepositoryImpl) getSharedCollectionsOptimized(ctx context.Context, userID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
return impl.GetCollectionsSharedWithUser(ctx, userID)
|
||||
}
|
||||
|
||||
// Alternative approach when you need both types efficiently
|
||||
func (impl *collectionRepositoryImpl) GetCollectionsWithFilterSingleQuery(ctx context.Context, options dom_collection.CollectionFilterOptions) (*dom_collection.CollectionFilterResult, error) {
|
||||
if !options.IsValid() {
|
||||
return nil, fmt.Errorf("invalid filter options: at least one filter must be enabled")
|
||||
}
|
||||
|
||||
// Strategy decision: If we need both owned AND shared collections,
|
||||
// it might be more efficient to query the original table once and separate them in memory
|
||||
if options.ShouldIncludeAll() {
|
||||
return impl.getAllCollectionsAndSeparate(ctx, options.UserID)
|
||||
}
|
||||
|
||||
// If we only need one type, use the optimized single-type methods
|
||||
return impl.GetCollectionsWithFilter(ctx, options)
|
||||
}
|
||||
|
||||
// Helper method that demonstrates memory-based separation when it's more efficient
|
||||
func (impl *collectionRepositoryImpl) getAllCollectionsAndSeparate(ctx context.Context, userID gocql.UUID) (*dom_collection.CollectionFilterResult, error) {
|
||||
result := &dom_collection.CollectionFilterResult{
|
||||
OwnedCollections: []*dom_collection.Collection{},
|
||||
SharedCollections: []*dom_collection.Collection{},
|
||||
TotalCount: 0,
|
||||
}
|
||||
|
||||
// Query the original table to get all collections for the user
|
||||
allCollections, err := impl.GetAllUserCollections(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get all user collections: %w", err)
|
||||
}
|
||||
|
||||
// Separate owned from shared collections in memory
|
||||
for _, collection := range allCollections {
|
||||
if collection.OwnerID == userID {
|
||||
result.OwnedCollections = append(result.OwnedCollections, collection)
|
||||
} else {
|
||||
// If the user is not the owner but has access, they must be a member
|
||||
result.SharedCollections = append(result.SharedCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
result.TotalCount = len(result.OwnedCollections) + len(result.SharedCollections)
|
||||
|
||||
impl.Logger.Debug("completed single-query filtered collection retrieval",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("total_retrieved", len(allCollections)),
|
||||
zap.Int("owned_count", len(result.OwnedCollections)),
|
||||
zap.Int("shared_count", len(result.SharedCollections)))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Advanced filtering with pagination support
|
||||
func (impl *collectionRepositoryImpl) GetCollectionsWithFilterPaginated(ctx context.Context, options dom_collection.CollectionFilterOptions, limit int64, cursor *dom_collection.CollectionSyncCursor) (*dom_collection.CollectionFilterResult, error) {
|
||||
if !options.IsValid() {
|
||||
return nil, fmt.Errorf("invalid filter options: at least one filter must be enabled")
|
||||
}
|
||||
|
||||
result := &dom_collection.CollectionFilterResult{
|
||||
OwnedCollections: []*dom_collection.Collection{},
|
||||
SharedCollections: []*dom_collection.Collection{},
|
||||
TotalCount: 0,
|
||||
}
|
||||
|
||||
if options.IncludeOwned {
|
||||
ownedCollections, err := impl.getOwnedCollectionsPaginated(ctx, options.UserID, limit, cursor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get paginated owned collections: %w", err)
|
||||
}
|
||||
result.OwnedCollections = ownedCollections
|
||||
}
|
||||
|
||||
if options.IncludeShared {
|
||||
sharedCollections, err := impl.getSharedCollectionsPaginated(ctx, options.UserID, limit, cursor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get paginated shared collections: %w", err)
|
||||
}
|
||||
result.SharedCollections = sharedCollections
|
||||
}
|
||||
|
||||
result.TotalCount = len(result.OwnedCollections) + len(result.SharedCollections)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Helper method for paginated owned collections - removed state filtering from query
|
||||
func (impl *collectionRepositoryImpl) getOwnedCollectionsPaginated(ctx context.Context, userID gocql.UUID, limit int64, cursor *dom_collection.CollectionSyncCursor) ([]*dom_collection.Collection, error) {
|
||||
var collectionIDs []gocql.UUID
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
// Build paginated query using the access-type-specific table - NO STATE FILTERING
|
||||
if cursor == nil {
|
||||
query = `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner' LIMIT ?`
|
||||
args = []any{userID, limit}
|
||||
} else {
|
||||
query = `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner' AND (modified_at, collection_id) > (?, ?) LIMIT ?`
|
||||
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
|
||||
}
|
||||
|
||||
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get paginated owned collections: %w", err)
|
||||
}
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
|
||||
// Helper method for paginated shared collections - removed state filtering from query
|
||||
func (impl *collectionRepositoryImpl) getSharedCollectionsPaginated(ctx context.Context, userID gocql.UUID, limit int64, cursor *dom_collection.CollectionSyncCursor) ([]*dom_collection.Collection, error) {
|
||||
var collectionIDs []gocql.UUID
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
// Build paginated query using the access-type-specific table - NO STATE FILTERING
|
||||
if cursor == nil {
|
||||
query = `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'member' LIMIT ?`
|
||||
args = []any{userID, limit}
|
||||
} else {
|
||||
query = `SELECT collection_id FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'member' AND (modified_at, collection_id) > (?, ?) LIMIT ?`
|
||||
args = []any{userID, cursor.LastModified, cursor.LastID, limit}
|
||||
}
|
||||
|
||||
iter := impl.Session.Query(query, args...).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get paginated shared collections: %w", err)
|
||||
}
|
||||
|
||||
// Load collections and filter by state in memory
|
||||
allCollections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range allCollections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/hierarchy.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) MoveCollection(
|
||||
ctx context.Context,
|
||||
collectionID,
|
||||
newParentID gocql.UUID,
|
||||
updatedAncestors []gocql.UUID,
|
||||
updatedPathSegments []string,
|
||||
) error {
|
||||
// Get the collection
|
||||
collection, err := impl.Get(ctx, collectionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
// Update hierarchy information
|
||||
collection.ParentID = newParentID
|
||||
collection.AncestorIDs = updatedAncestors
|
||||
collection.ModifiedAt = time.Now()
|
||||
collection.Version++
|
||||
|
||||
// Single update call handles all the complexity with the optimized schema
|
||||
return impl.Update(ctx, collection)
|
||||
}
|
||||
130
cloud/maplefile-backend/internal/repo/collection/impl.go
Normal file
130
cloud/maplefile-backend/internal/repo/collection/impl.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/impl.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
|
||||
)
|
||||
|
||||
type collectionRepositoryImpl struct {
|
||||
Logger *zap.Logger
|
||||
Session *gocql.Session
|
||||
}
|
||||
|
||||
func NewRepository(appCfg *config.Configuration, session *gocql.Session, loggerp *zap.Logger) dom_collection.CollectionRepository {
|
||||
loggerp = loggerp.Named("CollectionRepository")
|
||||
|
||||
return &collectionRepositoryImpl{
|
||||
Logger: loggerp,
|
||||
Session: session,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for JSON serialization
|
||||
func (impl *collectionRepositoryImpl) serializeAncestorIDs(ancestorIDs []gocql.UUID) (string, error) {
|
||||
if len(ancestorIDs) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
data, err := json.Marshal(ancestorIDs)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) deserializeAncestorIDs(data string) ([]gocql.UUID, error) {
|
||||
if data == "" || data == "[]" {
|
||||
return []gocql.UUID{}, nil
|
||||
}
|
||||
var ancestorIDs []gocql.UUID
|
||||
err := json.Unmarshal([]byte(data), &ancestorIDs)
|
||||
return ancestorIDs, err
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) serializeEncryptedCollectionKey(key *crypto.EncryptedCollectionKey) (string, error) {
|
||||
if key == nil {
|
||||
return "", nil
|
||||
}
|
||||
data, err := json.Marshal(key)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) deserializeEncryptedCollectionKey(data string) (*crypto.EncryptedCollectionKey, error) {
|
||||
if data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var key crypto.EncryptedCollectionKey
|
||||
err := json.Unmarshal([]byte(data), &key)
|
||||
return &key, err
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) serializeTags(tags []tag.EmbeddedTag) (string, error) {
|
||||
if len(tags) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
data, err := json.Marshal(tags)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) deserializeTags(data string) ([]tag.EmbeddedTag, error) {
|
||||
if data == "" || data == "[]" {
|
||||
return []tag.EmbeddedTag{}, nil
|
||||
}
|
||||
var tags []tag.EmbeddedTag
|
||||
err := json.Unmarshal([]byte(data), &tags)
|
||||
return tags, err
|
||||
}
|
||||
|
||||
// isValidUUID checks if UUID is not nil/empty
|
||||
func (impl *collectionRepositoryImpl) isValidUUID(id gocql.UUID) bool {
|
||||
return id.String() != "00000000-0000-0000-0000-000000000000"
|
||||
}
|
||||
|
||||
// Permission helper method
|
||||
func (impl *collectionRepositoryImpl) hasPermission(userPermission, requiredPermission string) bool {
|
||||
permissionLevels := map[string]int{
|
||||
dom_collection.CollectionPermissionReadOnly: 1,
|
||||
dom_collection.CollectionPermissionReadWrite: 2,
|
||||
dom_collection.CollectionPermissionAdmin: 3,
|
||||
}
|
||||
|
||||
userLevel, userExists := permissionLevels[userPermission]
|
||||
requiredLevel, requiredExists := permissionLevels[requiredPermission]
|
||||
|
||||
if !userExists || !requiredExists {
|
||||
return false
|
||||
}
|
||||
|
||||
return userLevel >= requiredLevel
|
||||
}
|
||||
|
||||
// Helper to generate null UUID for root collections
|
||||
func (impl *collectionRepositoryImpl) nullParentUUID() gocql.UUID {
|
||||
return gocql.UUID{} // All zeros represents null parent
|
||||
}
|
||||
|
||||
// Helper to build ancestor depth entries for hierarchy table
|
||||
func (impl *collectionRepositoryImpl) buildAncestorDepthEntries(collectionID gocql.UUID, ancestorIDs []gocql.UUID) []ancestorDepthEntry {
|
||||
var entries []ancestorDepthEntry
|
||||
|
||||
for i, ancestorID := range ancestorIDs {
|
||||
depth := i + 1 // Depth starts at 1 for direct parent
|
||||
entries = append(entries, ancestorDepthEntry{
|
||||
AncestorID: ancestorID,
|
||||
Depth: depth,
|
||||
CollectionID: collectionID,
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
type ancestorDepthEntry struct {
|
||||
AncestorID gocql.UUID
|
||||
Depth int
|
||||
CollectionID gocql.UUID
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/list_by_tag_id.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ListByTagID retrieves all collections that have the specified tag assigned
|
||||
// Uses the denormalized collections_by_tag_id table for efficient lookups
|
||||
func (impl *collectionRepositoryImpl) ListByTagID(ctx context.Context, tagID gocql.UUID) ([]*dom_collection.Collection, error) {
|
||||
impl.Logger.Info("🏷️ REPO: Listing collections by tag ID",
|
||||
zap.String("tag_id", tagID.String()))
|
||||
|
||||
var collectionIDs []gocql.UUID
|
||||
|
||||
// Query the denormalized table
|
||||
query := `SELECT collection_id FROM collections_by_tag_id WHERE tag_id = ?`
|
||||
|
||||
iter := impl.Session.Query(query, tagID).WithContext(ctx).Iter()
|
||||
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
impl.Logger.Error("🏷️ REPO: Failed to query collections by tag",
|
||||
zap.String("tag_id", tagID.String()),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to list collections by tag: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("🏷️ REPO: Found collection IDs for tag",
|
||||
zap.String("tag_id", tagID.String()),
|
||||
zap.Int("count", len(collectionIDs)))
|
||||
|
||||
// Load full collection details with members
|
||||
collections, err := impl.loadMultipleCollectionsWithMembers(ctx, collectionIDs)
|
||||
if err != nil {
|
||||
impl.Logger.Error("🏷️ REPO: Failed to load collections",
|
||||
zap.String("tag_id", tagID.String()),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only active collections
|
||||
var activeCollections []*dom_collection.Collection
|
||||
for _, collection := range collections {
|
||||
if collection.State == dom_collection.CollectionStateActive {
|
||||
activeCollections = append(activeCollections, collection)
|
||||
}
|
||||
}
|
||||
|
||||
impl.Logger.Info("🏷️ REPO: Successfully loaded collections by tag",
|
||||
zap.String("tag_id", tagID.String()),
|
||||
zap.Int("total_found", len(collections)),
|
||||
zap.Int("active_count", len(activeCollections)))
|
||||
|
||||
return activeCollections, nil
|
||||
}
|
||||
14
cloud/maplefile-backend/internal/repo/collection/provider.go
Normal file
14
cloud/maplefile-backend/internal/repo/collection/provider.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package collection
|
||||
|
||||
import (
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
// ProvideRepository provides a collection repository for Wire DI
|
||||
func ProvideRepository(cfg *config.Config, session *gocql.Session, logger *zap.Logger) dom_collection.CollectionRepository {
|
||||
return NewRepository(cfg, session, logger)
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
// RecalculateAllFileCounts recalculates the file_count for all collections
|
||||
// by counting active files in each collection. This is useful for fixing
|
||||
// collections that were created before file count tracking was implemented.
|
||||
func (impl *collectionRepositoryImpl) RecalculateAllFileCounts(ctx context.Context) (*dom_collection.RecalculateAllFileCountsResult, error) {
|
||||
impl.Logger.Info("Starting recalculation of all collection file counts")
|
||||
|
||||
result := &dom_collection.RecalculateAllFileCountsResult{}
|
||||
|
||||
// Get all collection IDs
|
||||
query := `SELECT id FROM maplefile.collections_by_id`
|
||||
iter := impl.Session.Query(query).WithContext(ctx).Iter()
|
||||
|
||||
var collectionIDs []gocql.UUID
|
||||
var collectionID gocql.UUID
|
||||
for iter.Scan(&collectionID) {
|
||||
collectionIDs = append(collectionIDs, collectionID)
|
||||
}
|
||||
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection IDs: %w", err)
|
||||
}
|
||||
|
||||
result.TotalCollections = len(collectionIDs)
|
||||
impl.Logger.Info("Found collections to process",
|
||||
zap.Int("count", result.TotalCollections))
|
||||
|
||||
// For each collection, count active files and update
|
||||
for _, colID := range collectionIDs {
|
||||
// Count active files in this collection
|
||||
countQuery := `SELECT COUNT(*) FROM maplefile.files_by_collection WHERE collection_id = ? AND state = 'active' ALLOW FILTERING`
|
||||
var fileCount int64
|
||||
if err := impl.Session.Query(countQuery, colID).WithContext(ctx).Scan(&fileCount); err != nil {
|
||||
impl.Logger.Error("Failed to count files for collection",
|
||||
zap.String("collection_id", colID.String()),
|
||||
zap.Error(err))
|
||||
result.ErrorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Update the collection's file_count
|
||||
updateQuery := `UPDATE maplefile.collections_by_id SET file_count = ? WHERE id = ?`
|
||||
if err := impl.Session.Query(updateQuery, fileCount, colID).WithContext(ctx).Exec(); err != nil {
|
||||
impl.Logger.Error("Failed to update file count for collection",
|
||||
zap.String("collection_id", colID.String()),
|
||||
zap.Int64("file_count", fileCount),
|
||||
zap.Error(err))
|
||||
result.ErrorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
result.UpdatedCount++
|
||||
impl.Logger.Debug("Updated file count for collection",
|
||||
zap.String("collection_id", colID.String()),
|
||||
zap.Int64("file_count", fileCount))
|
||||
}
|
||||
|
||||
impl.Logger.Info("Completed recalculation of all collection file counts",
|
||||
zap.Int("total", result.TotalCollections),
|
||||
zap.Int("updated", result.UpdatedCount),
|
||||
zap.Int("errors", result.ErrorCount))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
36
cloud/maplefile-backend/internal/repo/collection/restore.go
Normal file
36
cloud/maplefile-backend/internal/repo/collection/restore.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/restore.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) Restore(ctx context.Context, id gocql.UUID) error {
|
||||
collection, err := impl.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection for restore: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
// Validate state transition
|
||||
if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateActive); err != nil {
|
||||
return fmt.Errorf("invalid state transition: %w", err)
|
||||
}
|
||||
|
||||
// Update collection state
|
||||
collection.State = dom_collection.CollectionStateActive
|
||||
collection.ModifiedAt = time.Now()
|
||||
collection.Version++
|
||||
collection.TombstoneVersion = 0
|
||||
collection.TombstoneExpiry = time.Time{}
|
||||
|
||||
return impl.Update(ctx, collection)
|
||||
}
|
||||
496
cloud/maplefile-backend/internal/repo/collection/share.go
Normal file
496
cloud/maplefile-backend/internal/repo/collection/share.go
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/share.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) AddMember(ctx context.Context, collectionID gocql.UUID, membership *dom_collection.CollectionMembership) error {
|
||||
if membership == nil {
|
||||
return fmt.Errorf("membership cannot be nil")
|
||||
}
|
||||
|
||||
impl.Logger.Info("starting add member process",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(membership.RecipientEmail)),
|
||||
zap.String("permission_level", membership.PermissionLevel))
|
||||
|
||||
// Validate membership data with enhanced checks
|
||||
if !impl.isValidUUID(membership.RecipientID) {
|
||||
return fmt.Errorf("invalid recipient ID")
|
||||
}
|
||||
if membership.RecipientEmail == "" {
|
||||
return fmt.Errorf("recipient email is required")
|
||||
}
|
||||
if membership.PermissionLevel == "" {
|
||||
membership.PermissionLevel = dom_collection.CollectionPermissionReadOnly
|
||||
}
|
||||
|
||||
// CRITICAL: Validate encrypted collection key for shared members
|
||||
if len(membership.EncryptedCollectionKey) == 0 {
|
||||
impl.Logger.Error("CRITICAL: Attempt to add member without encrypted collection key",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(membership.RecipientEmail)),
|
||||
zap.Int("encrypted_key_length", len(membership.EncryptedCollectionKey)))
|
||||
return fmt.Errorf("encrypted collection key is required for shared members")
|
||||
}
|
||||
|
||||
// Additional validation: ensure the encrypted key is reasonable size
|
||||
if len(membership.EncryptedCollectionKey) < 32 {
|
||||
impl.Logger.Error("encrypted collection key appears too short",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Int("encrypted_key_length", len(membership.EncryptedCollectionKey)))
|
||||
return fmt.Errorf("encrypted collection key appears invalid (got %d bytes, expected at least 32)", len(membership.EncryptedCollectionKey))
|
||||
}
|
||||
|
||||
impl.Logger.Info("validated encrypted collection key for new member",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Int("encrypted_key_length", len(membership.EncryptedCollectionKey)))
|
||||
|
||||
// Load collection
|
||||
collection, err := impl.Get(ctx, collectionID)
|
||||
if err != nil {
|
||||
impl.Logger.Error("failed to get collection for member addition",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to get collection: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
impl.Logger.Info("loaded collection for member addition",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("collection_state", collection.State),
|
||||
zap.Int("existing_members", len(collection.Members)))
|
||||
|
||||
// Ensure member has an ID BEFORE adding to collection
|
||||
if !impl.isValidUUID(membership.ID) {
|
||||
membership.ID = gocql.TimeUUID()
|
||||
impl.Logger.Debug("generated new member ID", zap.String("member_id", membership.ID.String()))
|
||||
}
|
||||
|
||||
// Set creation time if not set
|
||||
if membership.CreatedAt.IsZero() {
|
||||
membership.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Set collection ID (ensure it matches)
|
||||
membership.CollectionID = collectionID
|
||||
|
||||
// Check if member already exists and update or add
|
||||
memberExists := false
|
||||
for i, existingMember := range collection.Members {
|
||||
if existingMember.RecipientID == membership.RecipientID {
|
||||
impl.Logger.Info("updating existing collection member",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.String("old_permission", existingMember.PermissionLevel),
|
||||
zap.String("new_permission", membership.PermissionLevel))
|
||||
|
||||
// IMPORTANT: Preserve the existing member ID to avoid creating a new one
|
||||
membership.ID = existingMember.ID
|
||||
collection.Members[i] = *membership
|
||||
memberExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !memberExists {
|
||||
impl.Logger.Info("adding new collection member",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.String("permission_level", membership.PermissionLevel))
|
||||
|
||||
collection.Members = append(collection.Members, *membership)
|
||||
|
||||
impl.Logger.Info("DEBUGGING: Member added to collection.Members slice",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("new_member_id", membership.ID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Int("total_members_now", len(collection.Members)))
|
||||
}
|
||||
|
||||
// Update version
|
||||
collection.Version++
|
||||
collection.ModifiedAt = time.Now()
|
||||
|
||||
impl.Logger.Info("prepared collection for update with member",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("total_members", len(collection.Members)),
|
||||
zap.Uint64("version", collection.Version))
|
||||
|
||||
// DEBUGGING: Log all members that will be sent to Update method
|
||||
impl.Logger.Info("DEBUGGING: About to call Update() with these members:")
|
||||
for debugIdx, debugMember := range collection.Members {
|
||||
isOwner := debugMember.RecipientID == collection.OwnerID
|
||||
impl.Logger.Info("DEBUGGING: Member in collection.Members slice",
|
||||
zap.Int("index", debugIdx),
|
||||
zap.String("member_id", debugMember.ID.String()),
|
||||
zap.String("recipient_id", debugMember.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(debugMember.RecipientEmail)),
|
||||
zap.String("permission_level", debugMember.PermissionLevel),
|
||||
zap.Bool("is_owner", isOwner),
|
||||
zap.Int("encrypted_key_length", len(debugMember.EncryptedCollectionKey)))
|
||||
}
|
||||
|
||||
// Log all members for debugging
|
||||
for i, member := range collection.Members {
|
||||
isOwner := member.RecipientID == collection.OwnerID
|
||||
impl.Logger.Debug("collection member details",
|
||||
zap.Int("member_index", i),
|
||||
zap.String("member_id", member.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("permission_level", member.PermissionLevel),
|
||||
zap.Bool("is_inherited", member.IsInherited),
|
||||
zap.Bool("is_owner", isOwner),
|
||||
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
|
||||
}
|
||||
|
||||
// Call update - the Update method itself is atomic and reliable
|
||||
err = impl.Update(ctx, collection)
|
||||
if err != nil {
|
||||
impl.Logger.Error("failed to update collection with new member",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to update collection: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("successfully added member to collection",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.String("member_id", membership.ID.String()))
|
||||
|
||||
// DEVELOPER NOTE:
|
||||
// Remove the immediate verification after update since Cassandra needs time to propagate:
|
||||
|
||||
// // DEBUGGING: Test if we can query the members table directly
|
||||
// impl.Logger.Info("DEBUGGING: Testing direct access to members table")
|
||||
// err = impl.testMembersTableAccess(ctx, collectionID)
|
||||
// if err != nil {
|
||||
// impl.Logger.Error("DEBUGGING: Failed to access members table",
|
||||
// zap.String("collection_id", collectionID.String()),
|
||||
// zap.Error(err))
|
||||
// } else {
|
||||
// impl.Logger.Info("DEBUGGING: Members table access test successful",
|
||||
// zap.String("collection_id", collectionID.String()))
|
||||
// }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// testDirectMemberInsert tests inserting directly into the members table (for debugging)
|
||||
func (impl *collectionRepositoryImpl) testDirectMemberInsert(ctx context.Context, collectionID gocql.UUID, membership *dom_collection.CollectionMembership) error {
|
||||
impl.Logger.Info("DEBUGGING: Testing direct insert into members table",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()))
|
||||
|
||||
query := `INSERT INTO collection_members_by_collection_id_and_recipient_id
|
||||
(collection_id, recipient_id, member_id, recipient_email, granted_by_id,
|
||||
encrypted_collection_key, permission_level, created_at,
|
||||
is_inherited, inherited_from_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
err := impl.Session.Query(query,
|
||||
collectionID, membership.RecipientID, membership.ID, membership.RecipientEmail,
|
||||
membership.GrantedByID, membership.EncryptedCollectionKey,
|
||||
membership.PermissionLevel, membership.CreatedAt,
|
||||
membership.IsInherited, membership.InheritedFromID).WithContext(ctx).Exec()
|
||||
|
||||
if err != nil {
|
||||
impl.Logger.Error("DEBUGGING: Direct insert failed",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("direct insert failed: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("DEBUGGING: Direct insert successful",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()))
|
||||
|
||||
// Verify the insert worked
|
||||
var foundMemberID gocql.UUID
|
||||
verifyQuery := `SELECT member_id FROM collection_members_by_collection_id_and_recipient_id
|
||||
WHERE collection_id = ? AND recipient_id = ?`
|
||||
|
||||
err = impl.Session.Query(verifyQuery, collectionID, membership.RecipientID).WithContext(ctx).Scan(&foundMemberID)
|
||||
if err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
impl.Logger.Error("DEBUGGING: Direct insert verification failed - member not found",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()))
|
||||
return fmt.Errorf("direct insert verification failed - member not found")
|
||||
}
|
||||
impl.Logger.Error("DEBUGGING: Direct insert verification error",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("verification query failed: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("DEBUGGING: Direct insert verification successful",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.String("found_member_id", foundMemberID.String()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// testMembersTableAccess verifies we can read from the members table
|
||||
func (impl *collectionRepositoryImpl) testMembersTableAccess(ctx context.Context, collectionID gocql.UUID) error {
|
||||
query := `SELECT COUNT(*) FROM collection_members_by_collection_id_and_recipient_id WHERE collection_id = ?`
|
||||
|
||||
var count int
|
||||
err := impl.Session.Query(query, collectionID).WithContext(ctx).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query members table: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("DEBUGGING: Members table query successful",
|
||||
zap.String("collection_id", collectionID.String()),
|
||||
zap.Int("member_count", count))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) RemoveMember(ctx context.Context, collectionID, recipientID gocql.UUID) error {
|
||||
// Load collection, remove member, and save
|
||||
collection, err := impl.Get(ctx, collectionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
// Remove member from collection
|
||||
var updatedMembers []dom_collection.CollectionMembership
|
||||
found := false
|
||||
|
||||
for _, member := range collection.Members {
|
||||
if member.RecipientID != recipientID {
|
||||
updatedMembers = append(updatedMembers, member)
|
||||
} else {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("member not found in collection")
|
||||
}
|
||||
|
||||
collection.Members = updatedMembers
|
||||
collection.Version++
|
||||
|
||||
return impl.Update(ctx, collection)
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) UpdateMemberPermission(ctx context.Context, collectionID, recipientID gocql.UUID, newPermission string) error {
|
||||
// Load collection, update member permission, and save
|
||||
collection, err := impl.Get(ctx, collectionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection: %w", err)
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
// Update member permission
|
||||
found := false
|
||||
for i, member := range collection.Members {
|
||||
if member.RecipientID == recipientID {
|
||||
collection.Members[i].PermissionLevel = newPermission
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("member not found in collection")
|
||||
}
|
||||
|
||||
collection.Version++
|
||||
return impl.Update(ctx, collection)
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) GetCollectionMembership(ctx context.Context, collectionID, recipientID gocql.UUID) (*dom_collection.CollectionMembership, error) {
|
||||
var membership dom_collection.CollectionMembership
|
||||
|
||||
query := `SELECT recipient_id, member_id, recipient_email, granted_by_id,
|
||||
encrypted_collection_key, permission_level, created_at,
|
||||
is_inherited, inherited_from_id
|
||||
FROM collection_members_by_collection_id_and_recipient_id
|
||||
WHERE collection_id = ? AND recipient_id = ?`
|
||||
|
||||
err := impl.Session.Query(query, collectionID, recipientID).WithContext(ctx).Scan(
|
||||
&membership.RecipientID, &membership.ID, &membership.RecipientEmail, &membership.GrantedByID,
|
||||
&membership.EncryptedCollectionKey, &membership.PermissionLevel,
|
||||
&membership.CreatedAt, &membership.IsInherited, &membership.InheritedFromID)
|
||||
|
||||
if err != nil {
|
||||
if err == gocql.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
membership.CollectionID = collectionID
|
||||
|
||||
return &membership, nil
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) AddMemberToHierarchy(ctx context.Context, rootID gocql.UUID, membership *dom_collection.CollectionMembership) error {
|
||||
// Get all descendants of the root collection
|
||||
descendants, err := impl.FindDescendants(ctx, rootID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find descendants: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("adding member to collection hierarchy",
|
||||
zap.String("root_collection_id", rootID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Int("descendants_count", len(descendants)))
|
||||
|
||||
// Add to root collection
|
||||
if err := impl.AddMember(ctx, rootID, membership); err != nil {
|
||||
return fmt.Errorf("failed to add member to root collection: %w", err)
|
||||
}
|
||||
|
||||
// Add to all descendants with inherited flag
|
||||
inheritedMembership := *membership
|
||||
inheritedMembership.IsInherited = true
|
||||
inheritedMembership.InheritedFromID = rootID
|
||||
|
||||
successCount := 0
|
||||
for _, descendant := range descendants {
|
||||
// Generate new ID for each inherited membership
|
||||
inheritedMembership.ID = gocql.TimeUUID()
|
||||
|
||||
if err := impl.AddMember(ctx, descendant.ID, &inheritedMembership); err != nil {
|
||||
impl.Logger.Warn("failed to add inherited member to descendant",
|
||||
zap.String("descendant_id", descendant.ID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
impl.Logger.Info("completed hierarchy member addition",
|
||||
zap.String("root_collection_id", rootID.String()),
|
||||
zap.String("recipient_id", membership.RecipientID.String()),
|
||||
zap.Int("total_descendants", len(descendants)),
|
||||
zap.Int("successful_additions", successCount))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (impl *collectionRepositoryImpl) RemoveMemberFromHierarchy(ctx context.Context, rootID, recipientID gocql.UUID) error {
|
||||
// Get all descendants of the root collection
|
||||
descendants, err := impl.FindDescendants(ctx, rootID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find descendants: %w", err)
|
||||
}
|
||||
|
||||
// Remove from root collection
|
||||
if err := impl.RemoveMember(ctx, rootID, recipientID); err != nil {
|
||||
return fmt.Errorf("failed to remove member from root collection: %w", err)
|
||||
}
|
||||
|
||||
// Remove from all descendants where access was inherited from this root
|
||||
for _, descendant := range descendants {
|
||||
// Only remove if the membership was inherited from this root
|
||||
membership, err := impl.GetCollectionMembership(ctx, descendant.ID, recipientID)
|
||||
if err != nil {
|
||||
impl.Logger.Warn("failed to get membership for descendant",
|
||||
zap.String("descendant_id", descendant.ID.String()),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if membership != nil && membership.IsInherited && membership.InheritedFromID == rootID {
|
||||
if err := impl.RemoveMember(ctx, descendant.ID, recipientID); err != nil {
|
||||
impl.Logger.Warn("failed to remove inherited member from descendant",
|
||||
zap.String("descendant_id", descendant.ID.String()),
|
||||
zap.String("recipient_id", recipientID.String()),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserFromAllCollections removes a user from all collections they are a member of
|
||||
// Used for GDPR right-to-be-forgotten implementation
|
||||
// Returns a list of collection IDs that were modified
|
||||
func (impl *collectionRepositoryImpl) RemoveUserFromAllCollections(ctx context.Context, userID gocql.UUID, userEmail string) ([]gocql.UUID, error) {
|
||||
impl.Logger.Info("Removing user from all shared collections",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("user_email", validation.MaskEmail(userEmail)))
|
||||
|
||||
// Get all collections shared with the user
|
||||
sharedCollections, err := impl.GetCollectionsSharedWithUser(ctx, userID)
|
||||
if err != nil {
|
||||
impl.Logger.Error("Failed to get collections shared with user",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get shared collections: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("Found shared collections for user",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("collection_count", len(sharedCollections)))
|
||||
|
||||
var modifiedCollections []gocql.UUID
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
|
||||
// Remove user from each collection
|
||||
for _, collection := range sharedCollections {
|
||||
err := impl.RemoveMember(ctx, collection.ID, userID)
|
||||
if err != nil {
|
||||
impl.Logger.Warn("Failed to remove user from collection",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
failureCount++
|
||||
// Continue with other collections despite error
|
||||
continue
|
||||
}
|
||||
|
||||
modifiedCollections = append(modifiedCollections, collection.ID)
|
||||
successCount++
|
||||
|
||||
impl.Logger.Debug("Removed user from collection",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("user_id", userID.String()))
|
||||
}
|
||||
|
||||
impl.Logger.Info("✅ Completed removing user from shared collections",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("total_collections", len(sharedCollections)),
|
||||
zap.Int("success_count", successCount),
|
||||
zap.Int("failure_count", failureCount),
|
||||
zap.Int("modified_collections", len(modifiedCollections)))
|
||||
|
||||
// Return success even if some removals failed - partial success is acceptable
|
||||
return modifiedCollections, nil
|
||||
}
|
||||
438
cloud/maplefile-backend/internal/repo/collection/update.go
Normal file
438
cloud/maplefile-backend/internal/repo/collection/update.go
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
// monorepo/cloud/maplefile-backend/internal/maplefile/repo/collection/update.go
|
||||
package collection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (impl *collectionRepositoryImpl) Update(ctx context.Context, collection *dom_collection.Collection) error {
|
||||
if collection == nil {
|
||||
return fmt.Errorf("collection cannot be nil")
|
||||
}
|
||||
|
||||
if !impl.isValidUUID(collection.ID) {
|
||||
return fmt.Errorf("collection ID is required")
|
||||
}
|
||||
|
||||
impl.Logger.Info("starting collection update",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Uint64("version", collection.Version),
|
||||
zap.Int("members_count", len(collection.Members)))
|
||||
|
||||
// Get existing collection to compare changes
|
||||
existing, err := impl.Get(ctx, collection.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing collection: %w", err)
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
return fmt.Errorf("collection not found")
|
||||
}
|
||||
|
||||
impl.Logger.Debug("loaded existing collection for comparison",
|
||||
zap.String("collection_id", existing.ID.String()),
|
||||
zap.Uint64("existing_version", existing.Version),
|
||||
zap.Int("existing_members_count", len(existing.Members)))
|
||||
|
||||
// Update modified timestamp
|
||||
collection.ModifiedAt = time.Now()
|
||||
|
||||
// Serialize complex fields
|
||||
ancestorIDsJSON, err := impl.serializeAncestorIDs(collection.AncestorIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize ancestor IDs: %w", err)
|
||||
}
|
||||
|
||||
encryptedKeyJSON, err := impl.serializeEncryptedCollectionKey(collection.EncryptedCollectionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize encrypted collection key: %w", err)
|
||||
}
|
||||
|
||||
tagsJSON, err := impl.serializeTags(collection.Tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize tags: %w", err)
|
||||
}
|
||||
|
||||
batch := impl.Session.NewBatch(gocql.LoggedBatch)
|
||||
|
||||
//
|
||||
// 1. Update main table
|
||||
//
|
||||
|
||||
batch.Query(`UPDATE collections_by_id SET
|
||||
owner_id = ?, encrypted_name = ?, collection_type = ?, encrypted_collection_key = ?,
|
||||
encrypted_custom_icon = ?, parent_id = ?, ancestor_ids = ?, file_count = ?, tags = ?, created_at = ?, created_by_user_id = ?,
|
||||
modified_at = ?, modified_by_user_id = ?, version = ?, state = ?,
|
||||
tombstone_version = ?, tombstone_expiry = ?
|
||||
WHERE id = ?`,
|
||||
collection.OwnerID, collection.EncryptedName, collection.CollectionType, encryptedKeyJSON,
|
||||
collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON, collection.FileCount, tagsJSON, collection.CreatedAt, collection.CreatedByUserID,
|
||||
collection.ModifiedAt, collection.ModifiedByUserID, collection.Version, collection.State,
|
||||
collection.TombstoneVersion, collection.TombstoneExpiry, collection.ID)
|
||||
|
||||
//
|
||||
// 2. Update BOTH user access tables for owner
|
||||
//
|
||||
|
||||
// Delete old owner entry from BOTH tables
|
||||
batch.Query(`DELETE FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND modified_at = ? AND collection_id = ?`,
|
||||
existing.OwnerID, existing.ModifiedAt, collection.ID)
|
||||
|
||||
batch.Query(`DELETE FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'owner' AND modified_at = ? AND collection_id = ?`,
|
||||
existing.OwnerID, existing.ModifiedAt, collection.ID)
|
||||
|
||||
// Insert new owner entry into BOTH tables
|
||||
batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, modified_at, collection_id, access_type, permission_level, state)
|
||||
VALUES (?, ?, ?, 'owner', ?, ?)`,
|
||||
collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State)
|
||||
|
||||
batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, access_type, modified_at, collection_id, permission_level, state)
|
||||
VALUES (?, 'owner', ?, ?, ?, ?)`,
|
||||
collection.OwnerID, collection.ModifiedAt, collection.ID, nil, collection.State)
|
||||
|
||||
//
|
||||
// 3. Update parent hierarchy if changed
|
||||
//
|
||||
|
||||
oldParentID := existing.ParentID
|
||||
if !impl.isValidUUID(oldParentID) {
|
||||
oldParentID = impl.nullParentUUID()
|
||||
}
|
||||
|
||||
newParentID := collection.ParentID
|
||||
if !impl.isValidUUID(newParentID) {
|
||||
newParentID = impl.nullParentUUID()
|
||||
}
|
||||
|
||||
if oldParentID != newParentID || existing.OwnerID != collection.OwnerID {
|
||||
// Remove from old parent in original table
|
||||
batch.Query(`DELETE FROM collections_by_parent_id_with_asc_created_at_and_asc_collection_id
|
||||
WHERE parent_id = ? AND created_at = ? AND collection_id = ?`,
|
||||
oldParentID, collection.CreatedAt, collection.ID)
|
||||
|
||||
// Add to new parent in original table
|
||||
batch.Query(`INSERT INTO collections_by_parent_id_with_asc_created_at_and_asc_collection_id
|
||||
(parent_id, created_at, collection_id, owner_id, state)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
newParentID, collection.CreatedAt, collection.ID, collection.OwnerID, collection.State)
|
||||
|
||||
// Remove from old parent+owner in composite table
|
||||
batch.Query(`DELETE FROM collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id
|
||||
WHERE parent_id = ? AND owner_id = ? AND created_at = ? AND collection_id = ?`,
|
||||
oldParentID, existing.OwnerID, collection.CreatedAt, collection.ID)
|
||||
|
||||
// Add to new parent+owner in composite table
|
||||
batch.Query(`INSERT INTO collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id
|
||||
(parent_id, owner_id, created_at, collection_id, state)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
newParentID, collection.OwnerID, collection.CreatedAt, collection.ID, collection.State)
|
||||
} else {
|
||||
// Update existing parent entry in original table
|
||||
batch.Query(`UPDATE collections_by_parent_id_with_asc_created_at_and_asc_collection_id SET
|
||||
owner_id = ?, state = ?
|
||||
WHERE parent_id = ? AND created_at = ? AND collection_id = ?`,
|
||||
collection.OwnerID, collection.State,
|
||||
newParentID, collection.CreatedAt, collection.ID)
|
||||
|
||||
// Update existing parent entry in composite table
|
||||
batch.Query(`UPDATE collections_by_parent_and_owner_id_with_asc_created_at_and_asc_collection_id SET
|
||||
state = ?
|
||||
WHERE parent_id = ? AND owner_id = ? AND created_at = ? AND collection_id = ?`,
|
||||
collection.State,
|
||||
newParentID, collection.OwnerID, collection.CreatedAt, collection.ID)
|
||||
}
|
||||
|
||||
//
|
||||
// 4. Update ancestor hierarchy
|
||||
//
|
||||
|
||||
oldAncestorEntries := impl.buildAncestorDepthEntries(collection.ID, existing.AncestorIDs)
|
||||
for _, entry := range oldAncestorEntries {
|
||||
batch.Query(`DELETE FROM collections_by_ancestor_id_with_asc_depth_and_asc_collection_id
|
||||
WHERE ancestor_id = ? AND depth = ? AND collection_id = ?`,
|
||||
entry.AncestorID, entry.Depth, entry.CollectionID)
|
||||
}
|
||||
|
||||
newAncestorEntries := impl.buildAncestorDepthEntries(collection.ID, collection.AncestorIDs)
|
||||
for _, entry := range newAncestorEntries {
|
||||
batch.Query(`INSERT INTO collections_by_ancestor_id_with_asc_depth_and_asc_collection_id
|
||||
(ancestor_id, depth, collection_id, state)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
entry.AncestorID, entry.Depth, entry.CollectionID, collection.State)
|
||||
}
|
||||
|
||||
//
|
||||
// 5. Update denormalized collections_by_tag_id table
|
||||
//
|
||||
|
||||
// Calculate tag changes
|
||||
oldTagsMap := make(map[gocql.UUID]bool)
|
||||
for _, tag := range existing.Tags {
|
||||
oldTagsMap[tag.ID] = true
|
||||
}
|
||||
|
||||
newTagsMap := make(map[gocql.UUID]bool)
|
||||
for _, tag := range collection.Tags {
|
||||
newTagsMap[tag.ID] = true
|
||||
}
|
||||
|
||||
// Delete entries for removed tags
|
||||
for tagID := range oldTagsMap {
|
||||
if !newTagsMap[tagID] {
|
||||
impl.Logger.Debug("removing collection from tag denormalized table",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("tag_id", tagID.String()))
|
||||
batch.Query(`DELETE FROM collections_by_tag_id
|
||||
WHERE tag_id = ? AND collection_id = ?`,
|
||||
tagID, collection.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert/Update entries for current tags
|
||||
for _, tag := range collection.Tags {
|
||||
impl.Logger.Debug("updating collection in tag denormalized table",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("tag_id", tag.ID.String()))
|
||||
|
||||
batch.Query(`INSERT INTO collections_by_tag_id
|
||||
(tag_id, collection_id, owner_id, encrypted_name, collection_type,
|
||||
encrypted_collection_key, encrypted_custom_icon, parent_id, ancestor_ids,
|
||||
file_count, tags, created_at, created_by_user_id, modified_at, modified_by_user_id,
|
||||
version, state, tombstone_version, tombstone_expiry,
|
||||
created_from_ip_address, modified_from_ip_address, ip_anonymized_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tag.ID, collection.ID, collection.OwnerID, collection.EncryptedName, collection.CollectionType,
|
||||
encryptedKeyJSON, collection.EncryptedCustomIcon, collection.ParentID, ancestorIDsJSON,
|
||||
collection.FileCount, tagsJSON, collection.CreatedAt, collection.CreatedByUserID,
|
||||
collection.ModifiedAt, collection.ModifiedByUserID, collection.Version, collection.State,
|
||||
collection.TombstoneVersion, collection.TombstoneExpiry,
|
||||
nil, nil, nil) // IP tracking fields not yet in domain model
|
||||
}
|
||||
|
||||
//
|
||||
// 6. Handle members - FIXED: Delete members individually with composite key
|
||||
//
|
||||
|
||||
impl.Logger.Info("processing member updates",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("old_members", len(existing.Members)),
|
||||
zap.Int("new_members", len(collection.Members)))
|
||||
|
||||
// Delete each existing member individually from the members table
|
||||
impl.Logger.Info("DEBUGGING: Deleting existing members individually from members table",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("existing_members_count", len(existing.Members)))
|
||||
|
||||
for _, oldMember := range existing.Members {
|
||||
impl.Logger.Debug("deleting member from members table",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("recipient_id", oldMember.RecipientID.String()))
|
||||
|
||||
batch.Query(`DELETE FROM collection_members_by_collection_id_and_recipient_id
|
||||
WHERE collection_id = ? AND recipient_id = ?`,
|
||||
collection.ID, oldMember.RecipientID)
|
||||
}
|
||||
|
||||
// Delete old member access entries from BOTH user access tables
|
||||
for _, oldMember := range existing.Members {
|
||||
impl.Logger.Debug("deleting old member access",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("recipient_id", oldMember.RecipientID.String()),
|
||||
zap.Time("old_modified_at", existing.ModifiedAt))
|
||||
|
||||
// Delete from original table
|
||||
batch.Query(`DELETE FROM collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND modified_at = ? AND collection_id = ?`,
|
||||
oldMember.RecipientID, existing.ModifiedAt, collection.ID)
|
||||
|
||||
// Delete from access-type-specific table
|
||||
batch.Query(`DELETE FROM collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
WHERE user_id = ? AND access_type = 'member' AND modified_at = ? AND collection_id = ?`,
|
||||
oldMember.RecipientID, existing.ModifiedAt, collection.ID)
|
||||
}
|
||||
|
||||
// Insert ALL new members into ALL tables
|
||||
impl.Logger.Info("DEBUGGING: About to insert members into tables",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("total_members_to_insert", len(collection.Members)))
|
||||
|
||||
for i, member := range collection.Members {
|
||||
impl.Logger.Info("inserting new member",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("member_index", i),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("permission_level", member.PermissionLevel),
|
||||
zap.Bool("is_inherited", member.IsInherited))
|
||||
|
||||
// Validate member data before insertion
|
||||
if !impl.isValidUUID(member.RecipientID) {
|
||||
return fmt.Errorf("invalid recipient ID for member %d", i)
|
||||
}
|
||||
if member.RecipientEmail == "" {
|
||||
return fmt.Errorf("recipient email is required for member %d", i)
|
||||
}
|
||||
if member.PermissionLevel == "" {
|
||||
return fmt.Errorf("permission level is required for member %d", i)
|
||||
}
|
||||
|
||||
// FIXED: Only require encrypted collection key for non-owner members
|
||||
// The owner has access to the collection key through their master key
|
||||
isOwner := member.RecipientID == collection.OwnerID
|
||||
if !isOwner && len(member.EncryptedCollectionKey) == 0 {
|
||||
impl.Logger.Error("CRITICAL: encrypted collection key missing for shared member",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("member_index", i),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("owner_id", collection.OwnerID.String()),
|
||||
zap.Bool("is_owner", isOwner),
|
||||
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
|
||||
return fmt.Errorf("VALIDATION ERROR: encrypted collection key is required for shared member %d (recipient: %s, email: %s). This indicates a frontend bug or API misuse.", i, member.RecipientID.String(), validation.MaskEmail(member.RecipientEmail))
|
||||
}
|
||||
|
||||
// Additional validation for shared members
|
||||
if !isOwner && len(member.EncryptedCollectionKey) > 0 && len(member.EncryptedCollectionKey) < 32 {
|
||||
impl.Logger.Error("encrypted collection key appears invalid for shared member",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("member_index", i),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
|
||||
return fmt.Errorf("encrypted collection key appears invalid for member %d (too short: %d bytes)", i, len(member.EncryptedCollectionKey))
|
||||
}
|
||||
|
||||
// Log key status for debugging
|
||||
impl.Logger.Debug("member key validation passed",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("member_index", i),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.Bool("is_owner", isOwner),
|
||||
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
|
||||
|
||||
// Ensure member has an ID - but don't regenerate if it already exists
|
||||
if !impl.isValidUUID(member.ID) {
|
||||
member.ID = gocql.TimeUUID()
|
||||
impl.Logger.Debug("generated member ID",
|
||||
zap.String("member_id", member.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()))
|
||||
} else {
|
||||
impl.Logger.Debug("using existing member ID",
|
||||
zap.String("member_id", member.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()))
|
||||
}
|
||||
|
||||
// Insert into normalized members table
|
||||
impl.Logger.Info("DEBUGGING: Inserting member into members table",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("member_index", i),
|
||||
zap.String("member_id", member.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("permission_level", member.PermissionLevel))
|
||||
|
||||
batch.Query(`INSERT INTO collection_members_by_collection_id_and_recipient_id
|
||||
(collection_id, recipient_id, member_id, recipient_email, granted_by_id,
|
||||
encrypted_collection_key, permission_level, created_at,
|
||||
is_inherited, inherited_from_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
collection.ID, member.RecipientID, member.ID, member.RecipientEmail,
|
||||
member.GrantedByID, member.EncryptedCollectionKey,
|
||||
member.PermissionLevel, member.CreatedAt,
|
||||
member.IsInherited, member.InheritedFromID)
|
||||
|
||||
impl.Logger.Info("DEBUGGING: Added member insert query to batch",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("member_id", member.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()))
|
||||
|
||||
// Insert into BOTH user access tables
|
||||
impl.Logger.Info("🔍 UPDATE: Inserting member into access tables",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("permission_level", member.PermissionLevel),
|
||||
zap.String("state", collection.State))
|
||||
|
||||
// Original table
|
||||
batch.Query(`INSERT INTO collections_by_user_id_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, modified_at, collection_id, access_type, permission_level, state)
|
||||
VALUES (?, ?, ?, 'member', ?, ?)`,
|
||||
member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State)
|
||||
|
||||
// Access-type-specific table (THIS IS THE ONE USED FOR LISTING SHARED COLLECTIONS)
|
||||
impl.Logger.Info("🔍 UPDATE: Adding query to batch for access-type table",
|
||||
zap.String("table", "collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id"),
|
||||
zap.String("user_id", member.RecipientID.String()),
|
||||
zap.String("access_type", "member"),
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Time("modified_at", collection.ModifiedAt))
|
||||
|
||||
batch.Query(`INSERT INTO collections_by_user_id_and_access_type_with_desc_modified_at_and_asc_collection_id
|
||||
(user_id, access_type, modified_at, collection_id, permission_level, state)
|
||||
VALUES (?, 'member', ?, ?, ?, ?)`,
|
||||
member.RecipientID, collection.ModifiedAt, collection.ID, member.PermissionLevel, collection.State)
|
||||
}
|
||||
|
||||
//
|
||||
// 6. Execute the batch
|
||||
//
|
||||
|
||||
impl.Logger.Info("executing batch update",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("batch_size", batch.Size()))
|
||||
|
||||
// Execute batch - ensures atomicity across all table updates
|
||||
impl.Logger.Info("DEBUGGING: About to execute batch with member inserts",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("batch_size", batch.Size()),
|
||||
zap.Int("members_in_batch", len(collection.Members)))
|
||||
|
||||
if err := impl.Session.ExecuteBatch(batch.WithContext(ctx)); err != nil {
|
||||
impl.Logger.Error("DEBUGGING: Batch execution failed",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("batch_size", batch.Size()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to update collection: %w", err)
|
||||
}
|
||||
|
||||
impl.Logger.Info("DEBUGGING: Batch execution completed successfully",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("batch_size", batch.Size()))
|
||||
|
||||
// Log summary of what was written
|
||||
impl.Logger.Info("🔍 UPDATE: Batch executed successfully - Summary",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.Int("members_written", len(collection.Members)))
|
||||
|
||||
for i, member := range collection.Members {
|
||||
impl.Logger.Info("🔍 UPDATE: Member written to database",
|
||||
zap.Int("index", i),
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("recipient_id", member.RecipientID.String()),
|
||||
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
|
||||
zap.String("permission_level", member.PermissionLevel))
|
||||
}
|
||||
|
||||
// Remove the immediate verification - Cassandra needs time to propagate
|
||||
// In production, we should trust the batch succeeded if no error was returned
|
||||
|
||||
impl.Logger.Info("collection updated successfully in all tables",
|
||||
zap.String("collection_id", collection.ID.String()),
|
||||
zap.String("old_owner", existing.OwnerID.String()),
|
||||
zap.String("new_owner", collection.OwnerID.String()),
|
||||
zap.Int("old_member_count", len(existing.Members)),
|
||||
zap.Int("new_member_count", len(collection.Members)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
38
cloud/maplefile-backend/internal/repo/filemetadata/check.go
Normal file
38
cloud/maplefile-backend/internal/repo/filemetadata/check.go
Normal 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
|
||||
}
|
||||
138
cloud/maplefile-backend/internal/repo/filemetadata/count.go
Normal file
138
cloud/maplefile-backend/internal/repo/filemetadata/count.go
Normal 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
|
||||
}
|
||||
327
cloud/maplefile-backend/internal/repo/filemetadata/create.go
Normal file
327
cloud/maplefile-backend/internal/repo/filemetadata/create.go
Normal 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
|
||||
}
|
||||
127
cloud/maplefile-backend/internal/repo/filemetadata/delete.go
Normal file
127
cloud/maplefile-backend/internal/repo/filemetadata/delete.go
Normal 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
|
||||
}
|
||||
217
cloud/maplefile-backend/internal/repo/filemetadata/get.go
Normal file
217
cloud/maplefile-backend/internal/repo/filemetadata/get.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
68
cloud/maplefile-backend/internal/repo/filemetadata/impl.go
Normal file
68
cloud/maplefile-backend/internal/repo/filemetadata/impl.go
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
247
cloud/maplefile-backend/internal/repo/filemetadata/update.go
Normal file
247
cloud/maplefile-backend/internal/repo/filemetadata/update.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
221
cloud/maplefile-backend/internal/repo/storagedailyusage/get.go
Normal file
221
cloud/maplefile-backend/internal/repo/storagedailyusage/get.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
148
cloud/maplefile-backend/internal/repo/storageusageevent/get.go
Normal file
148
cloud/maplefile-backend/internal/repo/storageusageevent/get.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
12
cloud/maplefile-backend/internal/repo/tag/provider.go
Normal file
12
cloud/maplefile-backend/internal/repo/tag/provider.go
Normal 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)
|
||||
}
|
||||
315
cloud/maplefile-backend/internal/repo/tag/tag.go
Normal file
315
cloud/maplefile-backend/internal/repo/tag/tag.go
Normal 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
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package templatedemailer
|
||||
|
||||
func (impl *templatedEmailer) SendBusinessVerificationEmail(email, verificationCode, firstName string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package templatedemailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func (impl *templatedEmailer) SendUserPasswordResetEmail(ctx context.Context, email, verificationCode, firstName string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package templatedemailer
|
||||
|
||||
func (impl *templatedEmailer) SendRetailerStoreActiveEmailToRetailers(retailerEmails []string, storeName string) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package templatedemailer
|
||||
|
||||
func (impl *templatedEmailer) SendNewUserTemporaryPasswordEmail(email, firstName, temporaryPassword string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package templatedemailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func (impl *templatedEmailer) SendUserVerificationEmail(ctx context.Context, email, verificationCode, firstName string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
47
cloud/maplefile-backend/internal/repo/user/check.go
Normal file
47
cloud/maplefile-backend/internal/repo/user/check.go
Normal 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
|
||||
}
|
||||
115
cloud/maplefile-backend/internal/repo/user/create.go
Normal file
115
cloud/maplefile-backend/internal/repo/user/create.go
Normal 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
|
||||
}
|
||||
68
cloud/maplefile-backend/internal/repo/user/delete.go
Normal file
68
cloud/maplefile-backend/internal/repo/user/delete.go
Normal 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)
|
||||
}
|
||||
199
cloud/maplefile-backend/internal/repo/user/get.go
Normal file
199
cloud/maplefile-backend/internal/repo/user/get.go
Normal 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
|
||||
}
|
||||
114
cloud/maplefile-backend/internal/repo/user/helpers.go
Normal file
114
cloud/maplefile-backend/internal/repo/user/helpers.go
Normal 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
|
||||
}
|
||||
29
cloud/maplefile-backend/internal/repo/user/impl.go
Normal file
29
cloud/maplefile-backend/internal/repo/user/impl.go
Normal 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
|
||||
}
|
||||
14
cloud/maplefile-backend/internal/repo/user/provider.go
Normal file
14
cloud/maplefile-backend/internal/repo/user/provider.go
Normal 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)
|
||||
}
|
||||
145
cloud/maplefile-backend/internal/repo/user/update.go
Normal file
145
cloud/maplefile-backend/internal/repo/user/update.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue