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,52 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/password"
|
||||
)
|
||||
|
||||
// CheckPasswordBreachUseCase checks if a password has been compromised in data breaches
|
||||
// CWE-521: Password breach checking to prevent use of compromised passwords
|
||||
type CheckPasswordBreachUseCase struct {
|
||||
breachChecker password.BreachChecker
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCheckPasswordBreachUseCase creates a new CheckPasswordBreachUseCase
|
||||
func ProvideCheckPasswordBreachUseCase(
|
||||
breachChecker password.BreachChecker,
|
||||
logger *zap.Logger,
|
||||
) *CheckPasswordBreachUseCase {
|
||||
return &CheckPasswordBreachUseCase{
|
||||
breachChecker: breachChecker,
|
||||
logger: logger.Named("check-password-breach-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute checks if a password has been found in known data breaches
|
||||
// Returns an error if the password has been breached
|
||||
func (uc *CheckPasswordBreachUseCase) Execute(ctx context.Context, passwordStr string) error {
|
||||
uc.logger.Debug("checking password against breach database")
|
||||
|
||||
breachCount, err := uc.breachChecker.CheckPassword(ctx, passwordStr)
|
||||
if err != nil {
|
||||
// Log error but don't fail registration/login if breach check fails
|
||||
// This is a defense-in-depth measure, not a critical security control
|
||||
uc.logger.Warn("failed to check password breach status (non-fatal)",
|
||||
zap.Error(err))
|
||||
return nil // Don't block user if service is down
|
||||
}
|
||||
|
||||
if breachCount > 0 {
|
||||
uc.logger.Warn("password found in data breaches",
|
||||
zap.Int("breach_count", breachCount))
|
||||
return fmt.Errorf("password has been found in %d data breaches and cannot be used", breachCount)
|
||||
}
|
||||
|
||||
uc.logger.Debug("password not found in breaches")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domaintenant "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
// CheckTenantSlugAvailabilityUseCase checks if a tenant slug is available
|
||||
type CheckTenantSlugAvailabilityUseCase struct {
|
||||
tenantRepo domaintenant.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCheckTenantSlugAvailabilityUseCase creates a new CheckTenantSlugAvailabilityUseCase
|
||||
func ProvideCheckTenantSlugAvailabilityUseCase(
|
||||
tenantRepo domaintenant.Repository,
|
||||
logger *zap.Logger,
|
||||
) *CheckTenantSlugAvailabilityUseCase {
|
||||
return &CheckTenantSlugAvailabilityUseCase{
|
||||
tenantRepo: tenantRepo,
|
||||
logger: logger.Named("check-tenant-slug-availability-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute checks if a tenant slug is available (not already taken)
|
||||
// CWE-203: Implements timing attack mitigation to prevent tenant enumeration
|
||||
func (uc *CheckTenantSlugAvailabilityUseCase) Execute(ctx context.Context, slug string) error {
|
||||
// Record start time for timing attack mitigation
|
||||
startTime := time.Now()
|
||||
|
||||
// Always perform the database lookup
|
||||
existingTenant, err := uc.tenantRepo.GetBySlug(ctx, slug)
|
||||
|
||||
// Store the result but don't return early - prevents timing leaks
|
||||
var resultError error
|
||||
|
||||
if err == nil && existingTenant != nil {
|
||||
// CWE-532: Use redacted tenant slug for logging
|
||||
uc.logger.Warn("tenant slug already exists",
|
||||
logger.TenantSlugHash(slug),
|
||||
logger.SafeTenantSlug("tenant_slug_redacted", slug))
|
||||
resultError = domaintenant.ErrTenantExists
|
||||
} else if err != nil && err != domaintenant.ErrTenantNotFound {
|
||||
// Real database error (not "not found")
|
||||
uc.logger.Error("failed to check tenant existence", zap.Error(err))
|
||||
resultError = err
|
||||
} else {
|
||||
// CWE-532: Use redacted tenant slug for logging
|
||||
// Slug is available (err == ErrTenantNotFound or no error with nil tenant)
|
||||
uc.logger.Debug("tenant slug is available",
|
||||
logger.TenantSlugHash(slug),
|
||||
logger.SafeTenantSlug("tenant_slug_redacted", slug))
|
||||
resultError = nil
|
||||
}
|
||||
|
||||
// CWE-203: Add random delay to prevent timing attacks
|
||||
// Ensures response time is similar whether tenant exists or not
|
||||
elapsed := time.Since(startTime)
|
||||
minResponseTime := 50 * time.Millisecond // Minimum response time
|
||||
maxJitter := 30 * time.Millisecond // Random jitter to add unpredictability
|
||||
|
||||
// Generate cryptographically secure random jitter
|
||||
jitterMs, _ := rand.Int(rand.Reader, big.NewInt(int64(maxJitter.Milliseconds())))
|
||||
jitter := time.Duration(jitterMs.Int64()) * time.Millisecond
|
||||
|
||||
targetDelay := minResponseTime + jitter
|
||||
if elapsed < targetDelay {
|
||||
time.Sleep(targetDelay - elapsed)
|
||||
}
|
||||
|
||||
return resultError
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainuser "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
// GetUserByEmailUseCase retrieves a user by email for authentication
|
||||
type GetUserByEmailUseCase struct {
|
||||
userRepo domainuser.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetUserByEmailUseCase creates a new GetUserByEmailUseCase
|
||||
func ProvideGetUserByEmailUseCase(
|
||||
userRepo domainuser.Repository,
|
||||
logger *zap.Logger,
|
||||
) *GetUserByEmailUseCase {
|
||||
return &GetUserByEmailUseCase{
|
||||
userRepo: userRepo,
|
||||
logger: logger.Named("get-user-by-email-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute retrieves a user by email globally (across all tenants)
|
||||
// Returns ErrInvalidCredentials instead of ErrUserNotFound for security (timing attack prevention)
|
||||
func (uc *GetUserByEmailUseCase) Execute(ctx context.Context, email string) (*domainuser.User, error) {
|
||||
user, err := uc.userRepo.GetByEmailGlobal(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, domainuser.ErrUserNotFound) {
|
||||
// CWE-532: Use hashed email to prevent PII in logs
|
||||
uc.logger.Warn("user not found for login",
|
||||
logger.EmailHash(email))
|
||||
// Return generic error to prevent email enumeration
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
uc.logger.Error("failed to get user by email", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// CWE-532: Use hashed email to prevent PII in logs
|
||||
uc.logger.Debug("user found for login",
|
||||
zap.String("user_id", user.ID),
|
||||
logger.EmailHash(email))
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/password"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/securestring"
|
||||
)
|
||||
|
||||
// HashPasswordUseCase handles password validation and hashing
|
||||
type HashPasswordUseCase struct {
|
||||
passwordProvider password.PasswordProvider
|
||||
passwordValidator password.PasswordValidator
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideHashPasswordUseCase creates a new HashPasswordUseCase
|
||||
func ProvideHashPasswordUseCase(
|
||||
passwordProvider password.PasswordProvider,
|
||||
passwordValidator password.PasswordValidator,
|
||||
logger *zap.Logger,
|
||||
) *HashPasswordUseCase {
|
||||
return &HashPasswordUseCase{
|
||||
passwordProvider: passwordProvider,
|
||||
passwordValidator: passwordValidator,
|
||||
logger: logger.Named("hash-password-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates password strength and returns the hashed password
|
||||
func (uc *HashPasswordUseCase) Execute(plainPassword string) (string, error) {
|
||||
// Validate password strength
|
||||
if err := uc.passwordValidator.ValidatePasswordStrength(plainPassword); err != nil {
|
||||
uc.logger.Warn("password validation failed", zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Hash the password using secure string
|
||||
securePassword, err := securestring.NewSecureString(plainPassword)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to create secure string", zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
defer securePassword.Wipe() // Clean up password from memory
|
||||
|
||||
passwordHash, err := uc.passwordProvider.GenerateHashFromPassword(securePassword)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to hash password", zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
uc.logger.Debug("password hashed successfully")
|
||||
return passwordHash, nil
|
||||
}
|
||||
153
cloud/maplepress-backend/internal/usecase/gateway/login.go
Normal file
153
cloud/maplepress-backend/internal/usecase/gateway/login.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
)
|
||||
|
||||
// LoginInput represents the input for user login
|
||||
type LoginInput struct {
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
|
||||
// LoginOutput represents the output after successful login
|
||||
type LoginOutput struct {
|
||||
UserID string
|
||||
UserEmail string
|
||||
UserName string
|
||||
UserRole string
|
||||
TenantID string
|
||||
}
|
||||
|
||||
// LoginUseCase handles user authentication
|
||||
// Orchestrates the login workflow by coordinating focused usecases
|
||||
type LoginUseCase struct {
|
||||
// Focused usecases
|
||||
getUserByEmailUC *GetUserByEmailUseCase
|
||||
verifyPasswordUC *VerifyPasswordUseCase
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLoginUseCase creates a new login use case
|
||||
func NewLoginUseCase(
|
||||
getUserByEmailUC *GetUserByEmailUseCase,
|
||||
verifyPasswordUC *VerifyPasswordUseCase,
|
||||
logger *zap.Logger,
|
||||
) *LoginUseCase {
|
||||
return &LoginUseCase{
|
||||
getUserByEmailUC: getUserByEmailUC,
|
||||
verifyPasswordUC: verifyPasswordUC,
|
||||
logger: logger.Named("login-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideLoginUseCase creates a new LoginUseCase for dependency injection
|
||||
func ProvideLoginUseCase(
|
||||
getUserByEmailUC *GetUserByEmailUseCase,
|
||||
verifyPasswordUC *VerifyPasswordUseCase,
|
||||
logger *zap.Logger,
|
||||
) *LoginUseCase {
|
||||
return NewLoginUseCase(getUserByEmailUC, verifyPasswordUC, logger)
|
||||
}
|
||||
|
||||
// Execute orchestrates the login workflow using focused usecases
|
||||
// CWE-208: Observable Timing Discrepancy - Uses timing-safe authentication
|
||||
func (uc *LoginUseCase) Execute(ctx context.Context, input *LoginInput) (*LoginOutput, error) {
|
||||
// CWE-532: Use hashed email to prevent PII in logs
|
||||
uc.logger.Info("authenticating user",
|
||||
logger.EmailHash(input.Email))
|
||||
|
||||
// Step 1: Get user by email globally (no tenant_id required for login)
|
||||
// Note: This returns ErrInvalidCredentials (not ErrUserNotFound) for security
|
||||
user, err := uc.getUserByEmailUC.Execute(ctx, input.Email)
|
||||
|
||||
// CWE-208: TIMING ATTACK MITIGATION
|
||||
// We must ALWAYS verify the password, even if the user doesn't exist.
|
||||
// This prevents timing-based user enumeration attacks.
|
||||
//
|
||||
// Timing attack scenario without mitigation:
|
||||
// - If user exists: database lookup (~10ms) + Argon2 hashing (~100ms) = ~110ms
|
||||
// - If user doesn't exist: database lookup (~10ms) = ~10ms
|
||||
// Attacker can measure response time to enumerate valid email addresses.
|
||||
//
|
||||
// With mitigation:
|
||||
// - If user exists: database lookup + Argon2 hashing
|
||||
// - If user doesn't exist: database lookup + Argon2 dummy hashing
|
||||
// Both paths take approximately the same time (~110ms).
|
||||
|
||||
var passwordHash string
|
||||
userExists := (err == nil && user != nil)
|
||||
|
||||
if userExists {
|
||||
// User exists - use real password hash
|
||||
if user.SecurityData != nil {
|
||||
passwordHash = user.SecurityData.PasswordHash
|
||||
}
|
||||
}
|
||||
// If user doesn't exist, passwordHash remains empty string
|
||||
// The verifyPasswordUC will use dummy hash for timing safety
|
||||
|
||||
// Step 2: Verify password - ALWAYS executed regardless of user existence
|
||||
if err := uc.verifyPasswordUC.ExecuteTimingSafe(input.Password, passwordHash, userExists); err != nil {
|
||||
// CWE-532: Use hashed email to prevent PII in logs
|
||||
if userExists {
|
||||
uc.logger.Warn("login failed: password verification failed",
|
||||
logger.EmailHash(input.Email),
|
||||
zap.String("tenant_id", user.TenantID))
|
||||
} else {
|
||||
uc.logger.Warn("login failed: user not found",
|
||||
logger.EmailHash(input.Email))
|
||||
}
|
||||
// Always return the same generic error regardless of reason
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Now check if user lookup failed (after timing-safe password verification)
|
||||
if err != nil {
|
||||
// This should never happen because ExecuteTimingSafe should have failed
|
||||
// But keep for safety
|
||||
uc.logger.Error("unexpected error after password verification", zap.Error(err))
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// CWE-532: Use hashed email to prevent PII in logs
|
||||
uc.logger.Info("user authenticated successfully",
|
||||
zap.String("user_id", user.ID),
|
||||
logger.EmailHash(user.Email),
|
||||
zap.String("tenant_id", user.TenantID))
|
||||
|
||||
// Convert role to string (1="executive", 2="manager", 3="staff")
|
||||
roleStr := getRoleString(user.Role)
|
||||
|
||||
// Step 3: Build output
|
||||
return &LoginOutput{
|
||||
UserID: user.ID,
|
||||
UserEmail: user.Email,
|
||||
UserName: user.Name,
|
||||
UserRole: roleStr,
|
||||
TenantID: user.TenantID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getRoleString converts numeric role to string representation
|
||||
func getRoleString(role int) string {
|
||||
switch role {
|
||||
case 1:
|
||||
return "executive"
|
||||
case 2:
|
||||
return "manager"
|
||||
case 3:
|
||||
return "staff"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
domaintenant "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
|
||||
domainuser "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
// RegisterInput represents the input for user registration validation
|
||||
type RegisterInput struct {
|
||||
Email string
|
||||
Password string
|
||||
FirstName string
|
||||
LastName string
|
||||
TenantName string
|
||||
TenantSlug string
|
||||
Timezone string
|
||||
|
||||
// Consent fields
|
||||
AgreeTermsOfService bool
|
||||
AgreePromotions bool
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool
|
||||
|
||||
// Optional: IP address for audit trail
|
||||
CreatedFromIPAddress string
|
||||
}
|
||||
|
||||
// ValidateRegistrationInputUseCase validates registration input
|
||||
type ValidateRegistrationInputUseCase struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateRegistrationInputUseCase creates a new ValidateRegistrationInputUseCase
|
||||
func ProvideValidateRegistrationInputUseCase(logger *zap.Logger) *ValidateRegistrationInputUseCase {
|
||||
return &ValidateRegistrationInputUseCase{
|
||||
logger: logger.Named("validate-registration-input-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates the registration input fields
|
||||
func (uc *ValidateRegistrationInputUseCase) Execute(input *RegisterInput) error {
|
||||
if input.Email == "" {
|
||||
uc.logger.Warn("email is required")
|
||||
return domainuser.ErrEmailRequired
|
||||
}
|
||||
|
||||
if input.Password == "" {
|
||||
uc.logger.Warn("password is required")
|
||||
return domainuser.ErrPasswordRequired
|
||||
}
|
||||
|
||||
if input.FirstName == "" {
|
||||
uc.logger.Warn("first name is required")
|
||||
return domainuser.ErrFirstNameRequired
|
||||
}
|
||||
|
||||
if input.LastName == "" {
|
||||
uc.logger.Warn("last name is required")
|
||||
return domainuser.ErrLastNameRequired
|
||||
}
|
||||
|
||||
if input.TenantName == "" {
|
||||
uc.logger.Warn("tenant name is required")
|
||||
return domaintenant.ErrNameRequired
|
||||
}
|
||||
|
||||
if input.TenantSlug == "" {
|
||||
uc.logger.Warn("tenant slug is required")
|
||||
return domaintenant.ErrSlugRequired
|
||||
}
|
||||
|
||||
// Validate Terms of Service agreement (REQUIRED)
|
||||
if !input.AgreeTermsOfService {
|
||||
uc.logger.Warn("terms of service agreement is required")
|
||||
return domainuser.ErrTermsOfServiceRequired
|
||||
}
|
||||
|
||||
// Note: AgreePromotions and AgreeToTrackingAcrossThirdPartyAppsAndServices
|
||||
// are optional (defaults to false if not provided)
|
||||
|
||||
// CWE-532: Use hashed/redacted fields to prevent PII in logs
|
||||
uc.logger.Debug("registration input validated successfully",
|
||||
logger.EmailHash(input.Email),
|
||||
logger.TenantSlugHash(input.TenantSlug),
|
||||
zap.Bool("agree_terms", input.AgreeTermsOfService),
|
||||
zap.Bool("agree_promotions", input.AgreePromotions),
|
||||
zap.Bool("agree_tracking", input.AgreeToTrackingAcrossThirdPartyAppsAndServices))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/password"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/securestring"
|
||||
)
|
||||
|
||||
// VerifyPasswordUseCase verifies a password against a hash
|
||||
type VerifyPasswordUseCase struct {
|
||||
passwordProvider password.PasswordProvider
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideVerifyPasswordUseCase creates a new VerifyPasswordUseCase
|
||||
func ProvideVerifyPasswordUseCase(
|
||||
passwordProvider password.PasswordProvider,
|
||||
logger *zap.Logger,
|
||||
) *VerifyPasswordUseCase {
|
||||
return &VerifyPasswordUseCase{
|
||||
passwordProvider: passwordProvider,
|
||||
logger: logger.Named("verify-password-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute verifies a plain password against a hashed password
|
||||
// Returns ErrInvalidCredentials if password doesn't match (for security)
|
||||
func (uc *VerifyPasswordUseCase) Execute(plainPassword, passwordHash string) error {
|
||||
// Create secure string from password
|
||||
securePassword, err := securestring.NewSecureString(plainPassword)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to create secure password", zap.Error(err))
|
||||
return fmt.Errorf("failed to process password: %w", err)
|
||||
}
|
||||
defer securePassword.Wipe() // Clean up password from memory
|
||||
|
||||
// Verify password
|
||||
match, err := uc.passwordProvider.ComparePasswordAndHash(securePassword, passwordHash)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to compare password and hash", zap.Error(err))
|
||||
return fmt.Errorf("failed to verify password: %w", err)
|
||||
}
|
||||
|
||||
if !match {
|
||||
uc.logger.Debug("password verification failed")
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
uc.logger.Debug("password verified successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteTimingSafe verifies a password in a timing-safe manner
|
||||
// CWE-208: Observable Timing Discrepancy - Prevents user enumeration via timing attacks
|
||||
//
|
||||
// This method ALWAYS performs password hashing, even when the user doesn't exist,
|
||||
// to ensure constant-time behavior regardless of whether the email exists in the system.
|
||||
//
|
||||
// Parameters:
|
||||
// - plainPassword: The password to verify
|
||||
// - passwordHash: The hash to compare against (empty string if user doesn't exist)
|
||||
// - userExists: Whether the user exists in the system
|
||||
//
|
||||
// Returns ErrInvalidCredentials if verification fails for any reason
|
||||
func (uc *VerifyPasswordUseCase) ExecuteTimingSafe(plainPassword, passwordHash string, userExists bool) error {
|
||||
// Create secure string from password
|
||||
securePassword, err := securestring.NewSecureString(plainPassword)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to create secure password", zap.Error(err))
|
||||
return fmt.Errorf("failed to process password: %w", err)
|
||||
}
|
||||
defer securePassword.Wipe() // Clean up password from memory
|
||||
|
||||
if !userExists || passwordHash == "" {
|
||||
// User doesn't exist or no password hash available
|
||||
// Perform dummy password hashing to maintain constant time
|
||||
uc.logger.Debug("performing timing-safe dummy password verification")
|
||||
match, err := uc.passwordProvider.ComparePasswordAndHash(securePassword, password.DummyPasswordHash)
|
||||
if err != nil {
|
||||
// Even if dummy verification fails, we don't care about the error
|
||||
// The important part is that we spent the same amount of time
|
||||
uc.logger.Debug("dummy password verification completed", zap.Error(err))
|
||||
}
|
||||
_ = match // Explicitly ignore the result
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// User exists - perform real password verification
|
||||
match, err := uc.passwordProvider.ComparePasswordAndHash(securePassword, passwordHash)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to compare password and hash", zap.Error(err))
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !match {
|
||||
uc.logger.Debug("password verification failed")
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
uc.logger.Debug("password verified successfully")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
|
||||
)
|
||||
|
||||
// CreatePageEntityUseCase creates a domain page entity from input
|
||||
type CreatePageEntityUseCase struct {
|
||||
ipEncryptor *ipcrypt.IPEncryptor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreatePageEntityUseCase creates a new CreatePageEntityUseCase
|
||||
func ProvideCreatePageEntityUseCase(
|
||||
ipEncryptor *ipcrypt.IPEncryptor,
|
||||
logger *zap.Logger,
|
||||
) *CreatePageEntityUseCase {
|
||||
return &CreatePageEntityUseCase{
|
||||
ipEncryptor: ipEncryptor,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute converts SyncPageInput to a domain Page entity
|
||||
func (uc *CreatePageEntityUseCase) Execute(
|
||||
siteID, tenantID gocql.UUID,
|
||||
input SyncPageInput,
|
||||
) (*domainpage.Page, error) {
|
||||
// Encrypt IP address (CWE-359: GDPR compliance)
|
||||
encryptedIP, err := uc.ipEncryptor.Encrypt(input.IPAddress)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to encrypt IP address",
|
||||
zap.String("page_id", input.PageID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return domainpage.NewPage(
|
||||
siteID,
|
||||
tenantID,
|
||||
input.PageID,
|
||||
input.Title,
|
||||
input.Content,
|
||||
input.Excerpt,
|
||||
input.URL,
|
||||
input.Status,
|
||||
input.PostType,
|
||||
input.Author,
|
||||
input.PublishedAt,
|
||||
input.ModifiedAt,
|
||||
encryptedIP,
|
||||
), nil
|
||||
}
|
||||
190
cloud/maplepress-backend/internal/usecase/page/delete.go
Normal file
190
cloud/maplepress-backend/internal/usecase/page/delete.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// DeletePagesUseCase handles page deletion
|
||||
type DeletePagesUseCase struct {
|
||||
pageRepo domainpage.Repository
|
||||
siteRepo domainsite.Repository
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeletePagesUseCase creates a new DeletePagesUseCase
|
||||
func ProvideDeletePagesUseCase(
|
||||
pageRepo domainpage.Repository,
|
||||
siteRepo domainsite.Repository,
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *DeletePagesUseCase {
|
||||
return &DeletePagesUseCase{
|
||||
pageRepo: pageRepo,
|
||||
siteRepo: siteRepo,
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DeletePagesInput is the input for deleting pages
|
||||
type DeletePagesInput struct {
|
||||
PageIDs []string `json:"page_ids"`
|
||||
}
|
||||
|
||||
// DeletePagesOutput is the output after deleting pages
|
||||
type DeletePagesOutput struct {
|
||||
DeletedCount int `json:"deleted_count"`
|
||||
DeindexedCount int `json:"deindexed_count"`
|
||||
FailedPages []string `json:"failed_pages,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Execute deletes pages from both database and search index
|
||||
func (uc *DeletePagesUseCase) Execute(ctx context.Context, tenantID, siteID gocql.UUID, input *DeletePagesInput) (*DeletePagesOutput, error) {
|
||||
uc.logger.Info("executing delete pages use case",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int("page_count", len(input.PageIDs)))
|
||||
|
||||
// Get site to validate
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
deletedCount := 0
|
||||
deindexedCount := 0
|
||||
var failedPages []string
|
||||
|
||||
// Delete pages from database
|
||||
if len(input.PageIDs) > 1 {
|
||||
// Use batch delete for multiple pages
|
||||
if err := uc.pageRepo.DeleteMultiple(ctx, siteID, input.PageIDs); err != nil {
|
||||
uc.logger.Error("failed to batch delete pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to delete pages: %w", err)
|
||||
}
|
||||
deletedCount = len(input.PageIDs)
|
||||
} else if len(input.PageIDs) == 1 {
|
||||
// Single page delete
|
||||
if err := uc.pageRepo.Delete(ctx, siteID, input.PageIDs[0]); err != nil {
|
||||
uc.logger.Error("failed to delete page",
|
||||
zap.String("page_id", input.PageIDs[0]),
|
||||
zap.Error(err))
|
||||
failedPages = append(failedPages, input.PageIDs[0])
|
||||
} else {
|
||||
deletedCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from search index
|
||||
if deletedCount > 0 {
|
||||
if len(input.PageIDs) > 1 {
|
||||
// Batch delete from Meilisearch
|
||||
_, err := uc.searchClient.DeleteDocuments(siteID.String(), input.PageIDs)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to delete documents from search index", zap.Error(err))
|
||||
// Don't fail the whole operation since database delete succeeded
|
||||
} else {
|
||||
deindexedCount = len(input.PageIDs)
|
||||
}
|
||||
} else if len(input.PageIDs) == 1 && len(failedPages) == 0 {
|
||||
// Single document delete
|
||||
_, err := uc.searchClient.DeleteDocument(siteID.String(), input.PageIDs[0])
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to delete document from search index",
|
||||
zap.String("page_id", input.PageIDs[0]),
|
||||
zap.Error(err))
|
||||
// Don't fail the whole operation since database delete succeeded
|
||||
} else {
|
||||
deindexedCount = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uc.logger.Info("pages deleted successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int("deleted", deletedCount),
|
||||
zap.Int("deindexed", deindexedCount),
|
||||
zap.Int("failed", len(failedPages)))
|
||||
|
||||
message := fmt.Sprintf("Successfully deleted %d pages from database, removed %d from search index", deletedCount, deindexedCount)
|
||||
if len(failedPages) > 0 {
|
||||
message += fmt.Sprintf(", failed %d pages", len(failedPages))
|
||||
}
|
||||
|
||||
return &DeletePagesOutput{
|
||||
DeletedCount: deletedCount,
|
||||
DeindexedCount: deindexedCount,
|
||||
FailedPages: failedPages,
|
||||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteAllPagesInput is the input for deleting all pages for a site
|
||||
type DeleteAllPagesInput struct{}
|
||||
|
||||
// ExecuteDeleteAll deletes all pages for a site
|
||||
func (uc *DeletePagesUseCase) ExecuteDeleteAll(ctx context.Context, tenantID, siteID gocql.UUID) (*DeletePagesOutput, error) {
|
||||
uc.logger.Info("executing delete all pages use case",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()))
|
||||
|
||||
// Get site to validate
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
// Count pages before deletion
|
||||
count, err := uc.pageRepo.CountBySiteID(ctx, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to count pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to count pages: %w", err)
|
||||
}
|
||||
|
||||
// Delete all pages from database
|
||||
if err := uc.pageRepo.DeleteBySiteID(ctx, siteID); err != nil {
|
||||
uc.logger.Error("failed to delete all pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to delete pages: %w", err)
|
||||
}
|
||||
|
||||
// Delete all documents from search index
|
||||
_, err = uc.searchClient.DeleteAllDocuments(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to delete all documents from search index", zap.Error(err))
|
||||
// Don't fail the whole operation since database delete succeeded
|
||||
}
|
||||
|
||||
uc.logger.Info("all pages deleted successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int64("count", count))
|
||||
|
||||
return &DeletePagesOutput{
|
||||
DeletedCount: int(count),
|
||||
DeindexedCount: int(count),
|
||||
Message: fmt.Sprintf("Successfully deleted all %d pages", count),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
)
|
||||
|
||||
// DeletePagesFromRepoUseCase deletes pages from the database repository
|
||||
type DeletePagesFromRepoUseCase struct {
|
||||
pageRepo domainpage.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeletePagesFromRepoUseCase creates a new DeletePagesFromRepoUseCase
|
||||
func ProvideDeletePagesFromRepoUseCase(
|
||||
pageRepo domainpage.Repository,
|
||||
logger *zap.Logger,
|
||||
) *DeletePagesFromRepoUseCase {
|
||||
return &DeletePagesFromRepoUseCase{
|
||||
pageRepo: pageRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DeletePagesResult contains the result of page deletion
|
||||
type DeletePagesResult struct {
|
||||
DeletedCount int
|
||||
FailedPages []string
|
||||
}
|
||||
|
||||
// Execute deletes specific pages from the database
|
||||
func (uc *DeletePagesFromRepoUseCase) Execute(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
pageIDs []string,
|
||||
) (*DeletePagesResult, error) {
|
||||
result := &DeletePagesResult{
|
||||
DeletedCount: 0,
|
||||
FailedPages: []string{},
|
||||
}
|
||||
|
||||
if len(pageIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Use batch delete for multiple pages
|
||||
if len(pageIDs) > 1 {
|
||||
if err := uc.pageRepo.DeleteMultiple(ctx, siteID, pageIDs); err != nil {
|
||||
uc.logger.Error("failed to batch delete pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to delete pages: %w", err)
|
||||
}
|
||||
result.DeletedCount = len(pageIDs)
|
||||
} else {
|
||||
// Single page delete
|
||||
if err := uc.pageRepo.Delete(ctx, siteID, pageIDs[0]); err != nil {
|
||||
uc.logger.Error("failed to delete page",
|
||||
zap.String("page_id", pageIDs[0]),
|
||||
zap.Error(err))
|
||||
result.FailedPages = append(result.FailedPages, pageIDs[0])
|
||||
} else {
|
||||
result.DeletedCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExecuteDeleteAll deletes all pages for a site from the database
|
||||
func (uc *DeletePagesFromRepoUseCase) ExecuteDeleteAll(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
) (int64, error) {
|
||||
// Count pages before deletion
|
||||
count, err := uc.pageRepo.CountBySiteID(ctx, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to count pages", zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to count pages: %w", err)
|
||||
}
|
||||
|
||||
// Delete all pages from database
|
||||
if err := uc.pageRepo.DeleteBySiteID(ctx, siteID); err != nil {
|
||||
uc.logger.Error("failed to delete all pages", zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to delete pages: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// DeletePagesFromSearchUseCase deletes pages from the search index
|
||||
type DeletePagesFromSearchUseCase struct {
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeletePagesFromSearchUseCase creates a new DeletePagesFromSearchUseCase
|
||||
func ProvideDeletePagesFromSearchUseCase(
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *DeletePagesFromSearchUseCase {
|
||||
return &DeletePagesFromSearchUseCase{
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute deletes specific pages from the search index
|
||||
func (uc *DeletePagesFromSearchUseCase) Execute(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
pageIDs []string,
|
||||
) (int, error) {
|
||||
if len(pageIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
deindexedCount := 0
|
||||
|
||||
// Batch delete from Meilisearch
|
||||
if len(pageIDs) > 1 {
|
||||
_, err := uc.searchClient.DeleteDocuments(siteID.String(), pageIDs)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to delete documents from search index", zap.Error(err))
|
||||
// Don't fail the whole operation since database delete may have succeeded
|
||||
return 0, nil
|
||||
}
|
||||
deindexedCount = len(pageIDs)
|
||||
} else {
|
||||
// Single document delete
|
||||
_, err := uc.searchClient.DeleteDocument(siteID.String(), pageIDs[0])
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to delete document from search index",
|
||||
zap.String("page_id", pageIDs[0]),
|
||||
zap.Error(err))
|
||||
// Don't fail the whole operation since database delete may have succeeded
|
||||
return 0, nil
|
||||
}
|
||||
deindexedCount = 1
|
||||
}
|
||||
|
||||
return deindexedCount, nil
|
||||
}
|
||||
|
||||
// ExecuteDeleteAll deletes all documents for a site from the search index
|
||||
func (uc *DeletePagesFromSearchUseCase) ExecuteDeleteAll(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
) error {
|
||||
_, err := uc.searchClient.DeleteAllDocuments(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to delete all documents from search index", zap.Error(err))
|
||||
// Don't fail the whole operation since database delete may have succeeded
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// EnsureSearchIndexUseCase ensures search index exists for a site
|
||||
type EnsureSearchIndexUseCase struct {
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideEnsureSearchIndexUseCase creates a new EnsureSearchIndexUseCase
|
||||
func ProvideEnsureSearchIndexUseCase(
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *EnsureSearchIndexUseCase {
|
||||
return &EnsureSearchIndexUseCase{
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute ensures the search index exists, creating it if necessary
|
||||
func (uc *EnsureSearchIndexUseCase) Execute(ctx context.Context, siteID gocql.UUID) error {
|
||||
indexExists, err := uc.searchClient.IndexExists(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to check index existence", zap.Error(err))
|
||||
return fmt.Errorf("failed to check search index: %w", err)
|
||||
}
|
||||
|
||||
if !indexExists {
|
||||
uc.logger.Info("creating search index", zap.String("site_id", siteID.String()))
|
||||
if err := uc.searchClient.CreateIndex(siteID.String()); err != nil {
|
||||
uc.logger.Error("failed to create index", zap.Error(err))
|
||||
return fmt.Errorf("failed to create search index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// ExecuteSearchQueryUseCase performs the actual search query
|
||||
type ExecuteSearchQueryUseCase struct {
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideExecuteSearchQueryUseCase creates a new ExecuteSearchQueryUseCase
|
||||
func ProvideExecuteSearchQueryUseCase(
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *ExecuteSearchQueryUseCase {
|
||||
return &ExecuteSearchQueryUseCase{
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute performs the search query against Meilisearch
|
||||
func (uc *ExecuteSearchQueryUseCase) Execute(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
query string,
|
||||
limit, offset int64,
|
||||
filter string,
|
||||
) (*search.SearchResult, error) {
|
||||
// Set default limits if not provided
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20 // Default to 20 results
|
||||
}
|
||||
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
// Build search request
|
||||
searchReq := search.SearchRequest{
|
||||
Query: query,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Filter: filter,
|
||||
}
|
||||
|
||||
// If no filter provided, default to only published pages
|
||||
if searchReq.Filter == "" {
|
||||
searchReq.Filter = "status = publish"
|
||||
}
|
||||
|
||||
// Perform search
|
||||
result, err := uc.searchClient.Search(siteID.String(), searchReq)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to search pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to search pages: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("search completed",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("query", query),
|
||||
zap.Int64("total_hits", result.TotalHits),
|
||||
zap.Int64("processing_time_ms", result.ProcessingTimeMs))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
)
|
||||
|
||||
// GetPageByIDUseCase retrieves a specific page by ID
|
||||
type GetPageByIDUseCase struct {
|
||||
pageRepo domainpage.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetPageByIDUseCase creates a new GetPageByIDUseCase
|
||||
func ProvideGetPageByIDUseCase(
|
||||
pageRepo domainpage.Repository,
|
||||
logger *zap.Logger,
|
||||
) *GetPageByIDUseCase {
|
||||
return &GetPageByIDUseCase{
|
||||
pageRepo: pageRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute retrieves a page by its ID
|
||||
func (uc *GetPageByIDUseCase) Execute(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
pageID string,
|
||||
) (*domainpage.Page, error) {
|
||||
// Get page from database
|
||||
page, err := uc.pageRepo.GetByID(ctx, siteID, pageID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get page",
|
||||
zap.String("page_id", pageID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("page not found")
|
||||
}
|
||||
|
||||
uc.logger.Info("page retrieved",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("page_id", pageID))
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
)
|
||||
|
||||
// GetPageStatisticsUseCase retrieves page count statistics
|
||||
type GetPageStatisticsUseCase struct {
|
||||
pageRepo domainpage.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetPageStatisticsUseCase creates a new GetPageStatisticsUseCase
|
||||
func ProvideGetPageStatisticsUseCase(
|
||||
pageRepo domainpage.Repository,
|
||||
logger *zap.Logger,
|
||||
) *GetPageStatisticsUseCase {
|
||||
return &GetPageStatisticsUseCase{
|
||||
pageRepo: pageRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// PageStatistics contains page count statistics
|
||||
type PageStatistics struct {
|
||||
TotalPages int64
|
||||
PublishedPages int64
|
||||
DraftPages int64
|
||||
}
|
||||
|
||||
// Execute retrieves page statistics for a site
|
||||
func (uc *GetPageStatisticsUseCase) Execute(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
) (*PageStatistics, error) {
|
||||
// Count total pages in database
|
||||
totalPages, err := uc.pageRepo.CountBySiteID(ctx, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to count pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to count pages: %w", err)
|
||||
}
|
||||
|
||||
// Get all pages to count by status (this could be optimized with a dedicated query)
|
||||
pages, err := uc.pageRepo.GetBySiteID(ctx, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get pages: %w", err)
|
||||
}
|
||||
|
||||
// Count pages by status
|
||||
var publishedPages, draftPages int64
|
||||
for _, page := range pages {
|
||||
if page.Status == "publish" {
|
||||
publishedPages++
|
||||
} else if page.Status == "draft" {
|
||||
draftPages++
|
||||
}
|
||||
}
|
||||
|
||||
uc.logger.Info("page statistics retrieved",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int64("total", totalPages),
|
||||
zap.Int64("published", publishedPages),
|
||||
zap.Int64("draft", draftPages))
|
||||
|
||||
return &PageStatistics{
|
||||
TotalPages: totalPages,
|
||||
PublishedPages: publishedPages,
|
||||
DraftPages: draftPages,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// GetSearchIndexStatusUseCase retrieves search index status information
|
||||
type GetSearchIndexStatusUseCase struct {
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetSearchIndexStatusUseCase creates a new GetSearchIndexStatusUseCase
|
||||
func ProvideGetSearchIndexStatusUseCase(
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *GetSearchIndexStatusUseCase {
|
||||
return &GetSearchIndexStatusUseCase{
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchIndexStatus contains search index status information
|
||||
type SearchIndexStatus struct {
|
||||
Status string // "not_created", "active", "error"
|
||||
DocumentCount int64
|
||||
}
|
||||
|
||||
// Execute retrieves search index status for a site
|
||||
func (uc *GetSearchIndexStatusUseCase) Execute(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
) (*SearchIndexStatus, error) {
|
||||
status := &SearchIndexStatus{
|
||||
Status: "not_created",
|
||||
DocumentCount: 0,
|
||||
}
|
||||
|
||||
// Check if index exists
|
||||
indexExists, err := uc.searchClient.IndexExists(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to check index existence", zap.Error(err))
|
||||
status.Status = "error"
|
||||
return status, nil
|
||||
}
|
||||
|
||||
if !indexExists {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Index exists, mark as active
|
||||
status.Status = "active"
|
||||
|
||||
// Get index stats
|
||||
stats, err := uc.searchClient.GetStats(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get index stats", zap.Error(err))
|
||||
// Don't change status to error, index is still active
|
||||
} else {
|
||||
status.DocumentCount = stats.NumberOfDocuments
|
||||
}
|
||||
|
||||
uc.logger.Info("search index status retrieved",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("status", status.Status),
|
||||
zap.Int64("doc_count", status.DocumentCount))
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// IncrementSearchCountUseCase increments the search request counter for a site
|
||||
type IncrementSearchCountUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideIncrementSearchCountUseCase creates a new IncrementSearchCountUseCase
|
||||
func ProvideIncrementSearchCountUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *IncrementSearchCountUseCase {
|
||||
return &IncrementSearchCountUseCase{
|
||||
siteRepo: siteRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute increments the search count and updates the site usage tracking
|
||||
func (uc *IncrementSearchCountUseCase) Execute(
|
||||
ctx context.Context,
|
||||
site *domainsite.Site,
|
||||
) error {
|
||||
// Increment search request count
|
||||
site.IncrementSearchCount()
|
||||
|
||||
uc.logger.Info("incremented search count",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.Int64("new_count", site.SearchRequestsCount))
|
||||
|
||||
// Update usage tracking in database
|
||||
if err := uc.siteRepo.UpdateUsage(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to update search usage", zap.Error(err))
|
||||
// Don't fail the search, just log the error and return
|
||||
return err
|
||||
}
|
||||
|
||||
uc.logger.Info("search usage updated successfully",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.Int64("search_count", site.SearchRequestsCount))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// IndexPageToSearchUseCase indexes pages to the search engine
|
||||
type IndexPageToSearchUseCase struct {
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideIndexPageToSearchUseCase creates a new IndexPageToSearchUseCase
|
||||
func ProvideIndexPageToSearchUseCase(
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *IndexPageToSearchUseCase {
|
||||
return &IndexPageToSearchUseCase{
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute indexes a batch of pages to Meilisearch
|
||||
func (uc *IndexPageToSearchUseCase) Execute(
|
||||
ctx context.Context,
|
||||
siteID gocql.UUID,
|
||||
pages []*domainpage.Page,
|
||||
) (int, error) {
|
||||
if len(pages) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Convert pages to search documents
|
||||
documents := make([]search.PageDocument, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
if page.ShouldIndex() {
|
||||
page.MarkIndexed()
|
||||
|
||||
doc := search.PageDocument{
|
||||
ID: page.PageID,
|
||||
SiteID: page.SiteID.String(),
|
||||
TenantID: page.TenantID.String(),
|
||||
Title: page.Title,
|
||||
Content: page.Content,
|
||||
Excerpt: page.Excerpt,
|
||||
URL: page.URL,
|
||||
Status: page.Status,
|
||||
PostType: page.PostType,
|
||||
Author: page.Author,
|
||||
PublishedAt: page.PublishedAt.Unix(),
|
||||
ModifiedAt: page.ModifiedAt.Unix(),
|
||||
}
|
||||
|
||||
documents = append(documents, doc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(documents) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Bulk index to Meilisearch
|
||||
_, err := uc.searchClient.AddDocuments(siteID.String(), documents)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to index documents", zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to index documents: %w", err)
|
||||
}
|
||||
|
||||
return len(documents), nil
|
||||
}
|
||||
134
cloud/maplepress-backend/internal/usecase/page/search.go
Normal file
134
cloud/maplepress-backend/internal/usecase/page/search.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// SearchPagesUseCase handles page search functionality
|
||||
type SearchPagesUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSearchPagesUseCase creates a new SearchPagesUseCase
|
||||
func ProvideSearchPagesUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *SearchPagesUseCase {
|
||||
return &SearchPagesUseCase{
|
||||
siteRepo: siteRepo,
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchPagesInput is the input for searching pages
|
||||
type SearchPagesInput struct {
|
||||
Query string `json:"query"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
Filter string `json:"filter,omitempty"` // e.g., "status = publish AND post_type = post"
|
||||
}
|
||||
|
||||
// SearchPagesOutput is the output after searching pages
|
||||
type SearchPagesOutput struct {
|
||||
Hits interface{} `json:"hits"` // meilisearch.Hits
|
||||
Query string `json:"query"`
|
||||
ProcessingTimeMs int64 `json:"processing_time_ms"`
|
||||
TotalHits int64 `json:"total_hits"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
// Execute performs a search on the site's indexed pages
|
||||
func (uc *SearchPagesUseCase) Execute(ctx context.Context, tenantID, siteID gocql.UUID, input *SearchPagesInput) (*SearchPagesOutput, error) {
|
||||
uc.logger.Info("executing search pages use case",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("query", input.Query))
|
||||
|
||||
// Get site to validate and check quotas
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
// No quota checking - usage-based billing (anti-abuse via rate limiting only)
|
||||
|
||||
// Set default limits if not provided
|
||||
limit := input.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20 // Default to 20 results
|
||||
}
|
||||
|
||||
offset := input.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
// Build search request
|
||||
searchReq := search.SearchRequest{
|
||||
Query: input.Query,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Filter: input.Filter,
|
||||
}
|
||||
|
||||
// If no filter provided, default to only published pages
|
||||
if searchReq.Filter == "" {
|
||||
searchReq.Filter = "status = publish"
|
||||
}
|
||||
|
||||
// Perform search
|
||||
result, err := uc.searchClient.Search(siteID.String(), searchReq)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to search pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to search pages: %w", err)
|
||||
}
|
||||
|
||||
// Increment search request count (for usage tracking/billing)
|
||||
site.IncrementSearchCount()
|
||||
uc.logger.Info("incremented search count",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int64("new_count", site.SearchRequestsCount))
|
||||
|
||||
if err := uc.siteRepo.UpdateUsage(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to update search usage", zap.Error(err))
|
||||
// Don't fail the search, just log the error
|
||||
} else {
|
||||
uc.logger.Info("search usage updated successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int64("search_count", site.SearchRequestsCount))
|
||||
}
|
||||
|
||||
uc.logger.Info("search completed successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("query", input.Query),
|
||||
zap.Int64("total_hits", result.TotalHits),
|
||||
zap.Int64("processing_time_ms", result.ProcessingTimeMs))
|
||||
|
||||
return &SearchPagesOutput{
|
||||
Hits: result.Hits,
|
||||
Query: result.Query,
|
||||
ProcessingTimeMs: result.ProcessingTimeMs,
|
||||
TotalHits: result.TotalHits,
|
||||
Limit: result.Limit,
|
||||
Offset: result.Offset,
|
||||
}, nil
|
||||
}
|
||||
199
cloud/maplepress-backend/internal/usecase/page/status.go
Normal file
199
cloud/maplepress-backend/internal/usecase/page/status.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
)
|
||||
|
||||
// GetSyncStatusUseCase handles retrieving synchronization status
|
||||
type GetSyncStatusUseCase struct {
|
||||
pageRepo domainpage.Repository
|
||||
siteRepo domainsite.Repository
|
||||
searchClient *search.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetSyncStatusUseCase creates a new GetSyncStatusUseCase
|
||||
func ProvideGetSyncStatusUseCase(
|
||||
pageRepo domainpage.Repository,
|
||||
siteRepo domainsite.Repository,
|
||||
searchClient *search.Client,
|
||||
logger *zap.Logger,
|
||||
) *GetSyncStatusUseCase {
|
||||
return &GetSyncStatusUseCase{
|
||||
pageRepo: pageRepo,
|
||||
siteRepo: siteRepo,
|
||||
searchClient: searchClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncStatusOutput provides synchronization status information
|
||||
type SyncStatusOutput struct {
|
||||
SiteID string `json:"site_id"`
|
||||
TotalPages int64 `json:"total_pages"`
|
||||
PublishedPages int64 `json:"published_pages"`
|
||||
DraftPages int64 `json:"draft_pages"`
|
||||
LastSyncedAt time.Time `json:"last_synced_at"`
|
||||
PagesIndexedMonth int64 `json:"pages_indexed_month"` // Usage tracking
|
||||
SearchRequestsMonth int64 `json:"search_requests_month"` // Usage tracking
|
||||
LastResetAt time.Time `json:"last_reset_at"` // Monthly billing cycle
|
||||
SearchIndexStatus string `json:"search_index_status"`
|
||||
SearchIndexDocCount int64 `json:"search_index_doc_count"`
|
||||
}
|
||||
|
||||
// Execute retrieves the current sync status for a site
|
||||
func (uc *GetSyncStatusUseCase) Execute(ctx context.Context, tenantID, siteID gocql.UUID) (*SyncStatusOutput, error) {
|
||||
uc.logger.Info("executing get sync status use case",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()))
|
||||
|
||||
// Get site to validate and get quota information
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
// Count total pages in database
|
||||
totalPages, err := uc.pageRepo.CountBySiteID(ctx, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to count pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to count pages: %w", err)
|
||||
}
|
||||
|
||||
// Get all pages to count by status (this could be optimized with a dedicated query)
|
||||
pages, err := uc.pageRepo.GetBySiteID(ctx, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get pages", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get pages: %w", err)
|
||||
}
|
||||
|
||||
// Count pages by status
|
||||
var publishedPages, draftPages int64
|
||||
for _, page := range pages {
|
||||
if page.Status == "publish" {
|
||||
publishedPages++
|
||||
} else if page.Status == "draft" {
|
||||
draftPages++
|
||||
}
|
||||
}
|
||||
|
||||
// Check search index status
|
||||
indexStatus := "not_created"
|
||||
var indexDocCount int64 = 0
|
||||
|
||||
indexExists, err := uc.searchClient.IndexExists(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to check index existence", zap.Error(err))
|
||||
indexStatus = "error"
|
||||
} else if indexExists {
|
||||
indexStatus = "active"
|
||||
|
||||
// Get index stats
|
||||
stats, err := uc.searchClient.GetStats(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get index stats", zap.Error(err))
|
||||
} else {
|
||||
indexDocCount = stats.NumberOfDocuments
|
||||
}
|
||||
}
|
||||
|
||||
uc.logger.Info("sync status retrieved successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int64("total_pages", totalPages),
|
||||
zap.Int64("published", publishedPages),
|
||||
zap.Int64("draft", draftPages))
|
||||
|
||||
return &SyncStatusOutput{
|
||||
SiteID: siteID.String(),
|
||||
TotalPages: totalPages,
|
||||
PublishedPages: publishedPages,
|
||||
DraftPages: draftPages,
|
||||
LastSyncedAt: site.LastIndexedAt,
|
||||
PagesIndexedMonth: site.MonthlyPagesIndexed,
|
||||
SearchRequestsMonth: site.SearchRequestsCount,
|
||||
LastResetAt: site.LastResetAt,
|
||||
SearchIndexStatus: indexStatus,
|
||||
SearchIndexDocCount: indexDocCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPageDetailsInput is the input for getting page details
|
||||
type GetPageDetailsInput struct {
|
||||
PageID string `json:"page_id"`
|
||||
}
|
||||
|
||||
// PageDetailsOutput provides detailed information about a specific page
|
||||
type PageDetailsOutput struct {
|
||||
PageID string `json:"page_id"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
PostType string `json:"post_type"`
|
||||
Author string `json:"author"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
IndexedAt time.Time `json:"indexed_at"`
|
||||
MeilisearchDocID string `json:"meilisearch_doc_id"`
|
||||
IsIndexed bool `json:"is_indexed"`
|
||||
}
|
||||
|
||||
// ExecuteGetPageDetails retrieves detailed information about a specific page
|
||||
func (uc *GetSyncStatusUseCase) ExecuteGetPageDetails(ctx context.Context, tenantID, siteID gocql.UUID, input *GetPageDetailsInput) (*PageDetailsOutput, error) {
|
||||
uc.logger.Info("executing get page details use case",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("page_id", input.PageID))
|
||||
|
||||
// Get site to validate
|
||||
_, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Get page from database
|
||||
page, err := uc.pageRepo.GetByID(ctx, siteID, input.PageID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get page", zap.Error(err))
|
||||
return nil, fmt.Errorf("page not found")
|
||||
}
|
||||
|
||||
// Check if page is indexed in Meilisearch
|
||||
isIndexed := !page.IndexedAt.IsZero()
|
||||
|
||||
uc.logger.Info("page details retrieved successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("page_id", input.PageID))
|
||||
|
||||
return &PageDetailsOutput{
|
||||
PageID: page.PageID,
|
||||
Title: page.Title,
|
||||
Excerpt: page.Excerpt,
|
||||
URL: page.URL,
|
||||
Status: page.Status,
|
||||
PostType: page.PostType,
|
||||
Author: page.Author,
|
||||
PublishedAt: page.PublishedAt,
|
||||
ModifiedAt: page.ModifiedAt,
|
||||
IndexedAt: page.IndexedAt,
|
||||
MeilisearchDocID: page.MeilisearchDocID,
|
||||
IsIndexed: isIndexed,
|
||||
}, nil
|
||||
}
|
||||
205
cloud/maplepress-backend/internal/usecase/page/sync.go
Normal file
205
cloud/maplepress-backend/internal/usecase/page/sync.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
|
||||
)
|
||||
|
||||
// SyncPagesUseCase handles page synchronization from WordPress
|
||||
type SyncPagesUseCase struct {
|
||||
pageRepo domainpage.Repository
|
||||
siteRepo domainsite.Repository
|
||||
searchClient *search.Client
|
||||
ipEncryptor *ipcrypt.IPEncryptor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSyncPagesUseCase creates a new SyncPagesUseCase
|
||||
func ProvideSyncPagesUseCase(
|
||||
pageRepo domainpage.Repository,
|
||||
siteRepo domainsite.Repository,
|
||||
searchClient *search.Client,
|
||||
ipEncryptor *ipcrypt.IPEncryptor,
|
||||
logger *zap.Logger,
|
||||
) *SyncPagesUseCase {
|
||||
return &SyncPagesUseCase{
|
||||
pageRepo: pageRepo,
|
||||
siteRepo: siteRepo,
|
||||
searchClient: searchClient,
|
||||
ipEncryptor: ipEncryptor,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncPageInput represents a single page to sync
|
||||
type SyncPageInput struct {
|
||||
PageID string `json:"page_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"` // publish, draft, trash
|
||||
PostType string `json:"post_type"` // page, post
|
||||
Author string `json:"author"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
IPAddress string `json:"-"` // Plain IP address (will be encrypted before storage), never exposed in JSON
|
||||
}
|
||||
|
||||
// SyncPagesInput is the input for syncing pages
|
||||
type SyncPagesInput struct {
|
||||
Pages []SyncPageInput `json:"pages"`
|
||||
}
|
||||
|
||||
// SyncPagesOutput is the output after syncing pages
|
||||
type SyncPagesOutput struct {
|
||||
SyncedCount int `json:"synced_count"`
|
||||
IndexedCount int `json:"indexed_count"`
|
||||
FailedPages []string `json:"failed_pages,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SyncPages syncs a batch of pages for a site
|
||||
func (uc *SyncPagesUseCase) SyncPages(ctx context.Context, tenantID, siteID gocql.UUID, input *SyncPagesInput) (*SyncPagesOutput, error) {
|
||||
uc.logger.Info("syncing pages",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int("page_count", len(input.Pages)))
|
||||
|
||||
// Get site to validate and check quotas
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
// No quota limits - usage-based billing (anti-abuse via rate limiting only)
|
||||
|
||||
// Ensure search index exists
|
||||
indexExists, err := uc.searchClient.IndexExists(siteID.String())
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to check index existence", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to check search index: %w", err)
|
||||
}
|
||||
|
||||
if !indexExists {
|
||||
uc.logger.Info("creating search index", zap.String("site_id", siteID.String()))
|
||||
if err := uc.searchClient.CreateIndex(siteID.String()); err != nil {
|
||||
uc.logger.Error("failed to create index", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to create search index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Process each page
|
||||
syncedCount := 0
|
||||
indexedCount := 0
|
||||
var failedPages []string
|
||||
var documentsToIndex []search.PageDocument
|
||||
|
||||
for _, pageInput := range input.Pages {
|
||||
// Encrypt IP address (CWE-359: GDPR compliance)
|
||||
encryptedIP, err := uc.ipEncryptor.Encrypt(pageInput.IPAddress)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to encrypt IP address",
|
||||
zap.String("page_id", pageInput.PageID),
|
||||
zap.Error(err))
|
||||
failedPages = append(failedPages, pageInput.PageID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create page entity
|
||||
page := domainpage.NewPage(
|
||||
siteID,
|
||||
site.TenantID,
|
||||
pageInput.PageID,
|
||||
pageInput.Title,
|
||||
pageInput.Content,
|
||||
pageInput.Excerpt,
|
||||
pageInput.URL,
|
||||
pageInput.Status,
|
||||
pageInput.PostType,
|
||||
pageInput.Author,
|
||||
pageInput.PublishedAt,
|
||||
pageInput.ModifiedAt,
|
||||
encryptedIP,
|
||||
)
|
||||
|
||||
// Upsert page to database
|
||||
if err := uc.pageRepo.Upsert(ctx, page); err != nil {
|
||||
uc.logger.Error("failed to upsert page",
|
||||
zap.String("page_id", pageInput.PageID),
|
||||
zap.Error(err))
|
||||
failedPages = append(failedPages, pageInput.PageID)
|
||||
continue
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
|
||||
// Only index published pages
|
||||
if page.ShouldIndex() {
|
||||
page.MarkIndexed()
|
||||
|
||||
// Prepare document for Meilisearch
|
||||
doc := search.PageDocument{
|
||||
ID: page.PageID,
|
||||
SiteID: page.SiteID.String(),
|
||||
TenantID: page.TenantID.String(),
|
||||
Title: page.Title,
|
||||
Content: page.Content,
|
||||
Excerpt: page.Excerpt,
|
||||
URL: page.URL,
|
||||
Status: page.Status,
|
||||
PostType: page.PostType,
|
||||
Author: page.Author,
|
||||
PublishedAt: page.PublishedAt.Unix(),
|
||||
ModifiedAt: page.ModifiedAt.Unix(),
|
||||
}
|
||||
|
||||
documentsToIndex = append(documentsToIndex, doc)
|
||||
}
|
||||
}
|
||||
|
||||
// Index documents in Meilisearch if any
|
||||
if len(documentsToIndex) > 0 {
|
||||
_, err := uc.searchClient.AddDocuments(siteID.String(), documentsToIndex)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to index documents", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to index documents: %w", err)
|
||||
}
|
||||
indexedCount = len(documentsToIndex)
|
||||
// Note: Usage tracking is handled by the service layer via UpdateSiteUsageUseCase
|
||||
}
|
||||
|
||||
uc.logger.Info("pages synced successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Int("synced", syncedCount),
|
||||
zap.Int("indexed", indexedCount),
|
||||
zap.Int("failed", len(failedPages)))
|
||||
|
||||
message := fmt.Sprintf("Successfully synced %d pages, indexed %d pages", syncedCount, indexedCount)
|
||||
if len(failedPages) > 0 {
|
||||
message += fmt.Sprintf(", failed %d pages", len(failedPages))
|
||||
}
|
||||
|
||||
return &SyncPagesOutput{
|
||||
SyncedCount: syncedCount,
|
||||
IndexedCount: indexedCount,
|
||||
FailedPages: failedPages,
|
||||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// UpdateSiteUsageUseCase updates site usage counters after indexing
|
||||
type UpdateSiteUsageUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideUpdateSiteUsageUseCase creates a new UpdateSiteUsageUseCase
|
||||
func ProvideUpdateSiteUsageUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *UpdateSiteUsageUseCase {
|
||||
return &UpdateSiteUsageUseCase{
|
||||
siteRepo: siteRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute updates the site's monthly page indexed count (for billing tracking)
|
||||
func (uc *UpdateSiteUsageUseCase) Execute(
|
||||
ctx context.Context,
|
||||
site *domainsite.Site,
|
||||
indexedCount int,
|
||||
) error {
|
||||
if indexedCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
site.IncrementMonthlyPageCount(int64(indexedCount))
|
||||
|
||||
if err := uc.siteRepo.UpdateUsage(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to update usage", zap.Error(err))
|
||||
// Don't fail the whole operation, just log the error
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
|
||||
)
|
||||
|
||||
// UpsertPageUseCase saves or updates a page in the repository
|
||||
type UpsertPageUseCase struct {
|
||||
pageRepo domainpage.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideUpsertPageUseCase creates a new UpsertPageUseCase
|
||||
func ProvideUpsertPageUseCase(
|
||||
pageRepo domainpage.Repository,
|
||||
logger *zap.Logger,
|
||||
) *UpsertPageUseCase {
|
||||
return &UpsertPageUseCase{
|
||||
pageRepo: pageRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute saves or updates a page in the database
|
||||
func (uc *UpsertPageUseCase) Execute(ctx context.Context, page *domainpage.Page) error {
|
||||
if err := uc.pageRepo.Upsert(ctx, page); err != nil {
|
||||
uc.logger.Error("failed to upsert page",
|
||||
zap.String("page_id", page.PageID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ValidateSiteUseCase validates site status and verification
|
||||
type ValidateSiteUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateSiteUseCase creates a new ValidateSiteUseCase
|
||||
func ProvideValidateSiteUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateSiteUseCase {
|
||||
return &ValidateSiteUseCase{
|
||||
siteRepo: siteRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates the site and returns it if valid
|
||||
func (uc *ValidateSiteUseCase) Execute(
|
||||
ctx context.Context,
|
||||
tenantID, siteID gocql.UUID,
|
||||
) (*domainsite.Site, error) {
|
||||
// Get site from repository
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ValidateSiteForDeletionUseCase validates that a site exists and is authorized for deletion
|
||||
type ValidateSiteForDeletionUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateSiteForDeletionUseCase creates a new ValidateSiteForDeletionUseCase
|
||||
func ProvideValidateSiteForDeletionUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateSiteForDeletionUseCase {
|
||||
return &ValidateSiteForDeletionUseCase{
|
||||
siteRepo: siteRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates the site for deletion operations
|
||||
func (uc *ValidateSiteForDeletionUseCase) Execute(
|
||||
ctx context.Context,
|
||||
tenantID, siteID gocql.UUID,
|
||||
) (*domainsite.Site, error) {
|
||||
// Get site from repository
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ValidateSiteForSearchUseCase validates that a site exists and is authorized for search
|
||||
type ValidateSiteForSearchUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateSiteForSearchUseCase creates a new ValidateSiteForSearchUseCase
|
||||
func ProvideValidateSiteForSearchUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateSiteForSearchUseCase {
|
||||
return &ValidateSiteForSearchUseCase{
|
||||
siteRepo: siteRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates the site for search operations
|
||||
func (uc *ValidateSiteForSearchUseCase) Execute(
|
||||
ctx context.Context,
|
||||
tenantID, siteID gocql.UUID,
|
||||
) (*domainsite.Site, error) {
|
||||
// Get site from repository
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ValidateSiteForStatusUseCase validates that a site exists and is authorized for status queries
|
||||
type ValidateSiteForStatusUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateSiteForStatusUseCase creates a new ValidateSiteForStatusUseCase
|
||||
func ProvideValidateSiteForStatusUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateSiteForStatusUseCase {
|
||||
return &ValidateSiteForStatusUseCase{
|
||||
siteRepo: siteRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates the site for status operations
|
||||
func (uc *ValidateSiteForStatusUseCase) Execute(
|
||||
ctx context.Context,
|
||||
tenantID, siteID gocql.UUID,
|
||||
) (*domainsite.Site, error) {
|
||||
// Get site from repository
|
||||
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Verify site is verified (skip for test mode)
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
|
||||
return nil, domainsite.ErrSiteNotVerified
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/apikey"
|
||||
)
|
||||
|
||||
// AuthenticateAPIKeyUseCase handles API key authentication
|
||||
type AuthenticateAPIKeyUseCase struct {
|
||||
repo domainsite.Repository
|
||||
apiKeyHasher apikey.Hasher
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideAuthenticateAPIKeyUseCase creates a new AuthenticateAPIKeyUseCase
|
||||
func ProvideAuthenticateAPIKeyUseCase(
|
||||
repo domainsite.Repository,
|
||||
apiKeyHasher apikey.Hasher,
|
||||
logger *zap.Logger,
|
||||
) *AuthenticateAPIKeyUseCase {
|
||||
return &AuthenticateAPIKeyUseCase{
|
||||
repo: repo,
|
||||
apiKeyHasher: apiKeyHasher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticateAPIKeyInput is the input for authenticating an API key
|
||||
type AuthenticateAPIKeyInput struct {
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// AuthenticateAPIKeyOutput is the output after authenticating an API key
|
||||
type AuthenticateAPIKeyOutput struct {
|
||||
Site *domainsite.Site
|
||||
}
|
||||
|
||||
// Execute authenticates an API key and returns the associated site
|
||||
func (uc *AuthenticateAPIKeyUseCase) Execute(ctx context.Context, input *AuthenticateAPIKeyInput) (*AuthenticateAPIKeyOutput, error) {
|
||||
// Hash the API key
|
||||
apiKeyHash := uc.apiKeyHasher.Hash(input.APIKey)
|
||||
|
||||
// Lookup site by API key hash (from sites_by_apikey table)
|
||||
site, err := uc.repo.GetByAPIKeyHash(ctx, apiKeyHash)
|
||||
if err != nil {
|
||||
uc.logger.Debug("API key authentication failed", zap.Error(err))
|
||||
return nil, domainsite.ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
// Verify API key using constant-time comparison
|
||||
if !uc.apiKeyHasher.Verify(input.APIKey, site.APIKeyHash) {
|
||||
uc.logger.Warn("API key hash mismatch",
|
||||
zap.String("site_id", site.ID.String()))
|
||||
return nil, domainsite.ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
// Check if site can access API (allows pending sites for initial setup)
|
||||
if !site.CanAccessAPI() {
|
||||
uc.logger.Warn("site cannot access API",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("status", site.Status),
|
||||
zap.Bool("verified", site.IsVerified))
|
||||
return nil, domainsite.ErrSiteNotActive
|
||||
}
|
||||
|
||||
uc.logger.Debug("API key authenticated successfully",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
|
||||
return &AuthenticateAPIKeyOutput{Site: site}, nil
|
||||
}
|
||||
155
cloud/maplepress-backend/internal/usecase/site/create.go
Normal file
155
cloud/maplepress-backend/internal/usecase/site/create.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/apikey"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
|
||||
)
|
||||
|
||||
// CreateSiteUseCase handles site creation business logic
|
||||
type CreateSiteUseCase struct {
|
||||
repo domainsite.Repository
|
||||
apiKeyGen apikey.Generator
|
||||
apiKeyHasher apikey.Hasher
|
||||
ipEncryptor *ipcrypt.IPEncryptor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreateSiteUseCase creates a new CreateSiteUseCase
|
||||
func ProvideCreateSiteUseCase(
|
||||
repo domainsite.Repository,
|
||||
apiKeyGen apikey.Generator,
|
||||
apiKeyHasher apikey.Hasher,
|
||||
ipEncryptor *ipcrypt.IPEncryptor,
|
||||
logger *zap.Logger,
|
||||
) *CreateSiteUseCase {
|
||||
return &CreateSiteUseCase{
|
||||
repo: repo,
|
||||
apiKeyGen: apiKeyGen,
|
||||
apiKeyHasher: apiKeyHasher,
|
||||
ipEncryptor: ipEncryptor,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSiteInput is the input for creating a site
|
||||
type CreateSiteInput struct {
|
||||
Domain string
|
||||
SiteURL string
|
||||
TestMode bool // true = generate test_sk_ key (skips verification)
|
||||
IPAddress string // Plain IP address (will be encrypted before storage)
|
||||
}
|
||||
|
||||
// CreateSiteOutput is the output after creating a site
|
||||
type CreateSiteOutput struct {
|
||||
ID string `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
SiteURL string `json:"site_url"`
|
||||
APIKey string `json:"api_key"` // ONLY shown once!
|
||||
VerificationToken string `json:"verification_token"`
|
||||
Status string `json:"status"`
|
||||
SearchIndexName string `json:"search_index_name"`
|
||||
}
|
||||
|
||||
// Execute creates a new site
|
||||
func (uc *CreateSiteUseCase) Execute(ctx context.Context, tenantID gocql.UUID, input *CreateSiteInput) (*CreateSiteOutput, error) {
|
||||
uc.logger.Info("executing create site use case",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("domain", input.Domain))
|
||||
|
||||
// Generate API key (test or live based on test_mode)
|
||||
var apiKey string
|
||||
var err error
|
||||
if input.TestMode {
|
||||
apiKey, err = uc.apiKeyGen.GenerateTest() // test_sk_...
|
||||
uc.logger.Info("generating test API key for development")
|
||||
} else {
|
||||
apiKey, err = uc.apiKeyGen.Generate() // live_sk_...
|
||||
}
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to generate API key", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to generate API key: %w", err)
|
||||
}
|
||||
|
||||
// Hash API key
|
||||
apiKeyHash := uc.apiKeyHasher.Hash(apiKey)
|
||||
apiKeyPrefix := apikey.ExtractPrefix(apiKey)
|
||||
apiKeyLastFour := apikey.ExtractLastFour(apiKey)
|
||||
|
||||
// Generate verification token
|
||||
verificationToken, err := generateVerificationToken()
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to generate verification token", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to generate verification token: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt IP address (CWE-359: GDPR compliance)
|
||||
encryptedIP, err := uc.ipEncryptor.Encrypt(input.IPAddress)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to encrypt IP address",
|
||||
zap.String("domain", input.Domain),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to encrypt IP address: %w", err)
|
||||
}
|
||||
|
||||
// Create site entity (no plan tier - usage-based billing)
|
||||
site := domainsite.NewSite(
|
||||
tenantID,
|
||||
input.Domain,
|
||||
input.SiteURL,
|
||||
apiKeyHash,
|
||||
apiKeyPrefix,
|
||||
apiKeyLastFour,
|
||||
encryptedIP,
|
||||
)
|
||||
site.VerificationToken = verificationToken
|
||||
|
||||
// Check if domain already exists
|
||||
exists, err := uc.repo.DomainExists(ctx, input.Domain)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to check domain existence", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to check domain: %w", err)
|
||||
}
|
||||
if exists {
|
||||
uc.logger.Warn("domain already exists", zap.String("domain", input.Domain))
|
||||
return nil, domainsite.ErrDomainAlreadyExists
|
||||
}
|
||||
|
||||
// Create in repository (writes to all 4 Cassandra tables)
|
||||
if err := uc.repo.Create(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to create site", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uc.logger.Info("site created successfully",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
|
||||
return &CreateSiteOutput{
|
||||
ID: site.ID.String(),
|
||||
Domain: site.Domain,
|
||||
SiteURL: site.SiteURL,
|
||||
APIKey: apiKey, // PLAINTEXT - only shown once!
|
||||
VerificationToken: verificationToken,
|
||||
Status: site.Status,
|
||||
SearchIndexName: site.SearchIndexName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateVerificationToken generates a cryptographically secure verification token
|
||||
func generateVerificationToken() (string, error) {
|
||||
b := make([]byte, 16) // 16 bytes = 128 bits
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(b)
|
||||
return "mvp_" + token, nil // mvp = maplepress verify
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
|
||||
)
|
||||
|
||||
// CreateSiteEntityUseCase creates a site domain entity
|
||||
type CreateSiteEntityUseCase struct {
|
||||
ipEncryptor *ipcrypt.IPEncryptor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreateSiteEntityUseCase creates a new CreateSiteEntityUseCase
|
||||
func ProvideCreateSiteEntityUseCase(
|
||||
ipEncryptor *ipcrypt.IPEncryptor,
|
||||
logger *zap.Logger,
|
||||
) *CreateSiteEntityUseCase {
|
||||
return &CreateSiteEntityUseCase{
|
||||
ipEncryptor: ipEncryptor,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSiteEntityInput contains the data needed to create a site entity
|
||||
type CreateSiteEntityInput struct {
|
||||
TenantID gocql.UUID
|
||||
Domain string
|
||||
SiteURL string
|
||||
APIKeyHash string
|
||||
APIKeyPrefix string
|
||||
APIKeyLastFour string
|
||||
VerificationToken string
|
||||
IPAddress string // Plain IP address (will be encrypted before storage)
|
||||
}
|
||||
|
||||
// Execute creates a new site domain entity
|
||||
func (uc *CreateSiteEntityUseCase) Execute(input *CreateSiteEntityInput) (*domainsite.Site, error) {
|
||||
// Encrypt IP address (CWE-359: GDPR compliance)
|
||||
encryptedIP, err := uc.ipEncryptor.Encrypt(input.IPAddress)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to encrypt IP address",
|
||||
zap.String("domain", input.Domain),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
site := domainsite.NewSite(
|
||||
input.TenantID,
|
||||
input.Domain,
|
||||
input.SiteURL,
|
||||
input.APIKeyHash,
|
||||
input.APIKeyPrefix,
|
||||
input.APIKeyLastFour,
|
||||
encryptedIP,
|
||||
)
|
||||
site.VerificationToken = input.VerificationToken
|
||||
|
||||
uc.logger.Info("site entity created",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
|
||||
return site, nil
|
||||
}
|
||||
60
cloud/maplepress-backend/internal/usecase/site/delete.go
Normal file
60
cloud/maplepress-backend/internal/usecase/site/delete.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// DeleteSiteUseCase handles site deletion
|
||||
// DEPRECATED: This usecase is too simple but doesn't follow the refactored pattern.
|
||||
// Use the service layer (service/site/delete.go) which orchestrates
|
||||
// focused usecases: ValidateSiteForDeletionUseCase, DeleteSiteFromRepoUseCase.
|
||||
// This will be removed after migration is complete.
|
||||
type DeleteSiteUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeleteSiteUseCase creates a new DeleteSiteUseCase
|
||||
func ProvideDeleteSiteUseCase(repo domainsite.Repository, logger *zap.Logger) *DeleteSiteUseCase {
|
||||
return &DeleteSiteUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSiteInput is the input for deleting a site
|
||||
type DeleteSiteInput struct {
|
||||
SiteID string
|
||||
}
|
||||
|
||||
// DeleteSiteOutput is the output after deleting a site
|
||||
type DeleteSiteOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Execute deletes a site
|
||||
func (uc *DeleteSiteUseCase) Execute(ctx context.Context, tenantID gocql.UUID, input *DeleteSiteInput) (*DeleteSiteOutput, error) {
|
||||
siteID, err := gocql.ParseUUID(input.SiteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete from repository (removes from all 4 tables)
|
||||
if err := uc.repo.Delete(ctx, tenantID, siteID); err != nil {
|
||||
uc.logger.Error("failed to delete site", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uc.logger.Info("site deleted successfully", zap.String("site_id", siteID.String()))
|
||||
|
||||
return &DeleteSiteOutput{
|
||||
Success: true,
|
||||
Message: "Site deleted successfully",
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// DeleteSiteFromRepoUseCase deletes a site from the repository
|
||||
type DeleteSiteFromRepoUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeleteSiteFromRepoUseCase creates a new DeleteSiteFromRepoUseCase
|
||||
func ProvideDeleteSiteFromRepoUseCase(
|
||||
repo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *DeleteSiteFromRepoUseCase {
|
||||
return &DeleteSiteFromRepoUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute deletes a site from all repository tables
|
||||
func (uc *DeleteSiteFromRepoUseCase) Execute(ctx context.Context, tenantID, siteID gocql.UUID) error {
|
||||
// Delete from repository (removes from all 4 Cassandra tables)
|
||||
if err := uc.repo.Delete(ctx, tenantID, siteID); err != nil {
|
||||
uc.logger.Error("failed to delete site from repository",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to delete site: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("site deleted from repository",
|
||||
zap.String("site_id", siteID.String()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/apikey"
|
||||
)
|
||||
|
||||
// GenerateAPIKeyUseCase generates and hashes an API key
|
||||
type GenerateAPIKeyUseCase struct {
|
||||
apiKeyGen apikey.Generator
|
||||
apiKeyHasher apikey.Hasher
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGenerateAPIKeyUseCase creates a new GenerateAPIKeyUseCase
|
||||
func ProvideGenerateAPIKeyUseCase(
|
||||
apiKeyGen apikey.Generator,
|
||||
apiKeyHasher apikey.Hasher,
|
||||
logger *zap.Logger,
|
||||
) *GenerateAPIKeyUseCase {
|
||||
return &GenerateAPIKeyUseCase{
|
||||
apiKeyGen: apiKeyGen,
|
||||
apiKeyHasher: apiKeyHasher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// APIKeyResult contains the generated API key details
|
||||
type APIKeyResult struct {
|
||||
PlaintextKey string
|
||||
HashedKey string
|
||||
Prefix string
|
||||
LastFour string
|
||||
}
|
||||
|
||||
// Execute generates an API key (test or live) and returns its details
|
||||
func (uc *GenerateAPIKeyUseCase) Execute(testMode bool) (*APIKeyResult, error) {
|
||||
// Generate API key (test or live based on test_mode)
|
||||
var apiKey string
|
||||
var err error
|
||||
if testMode {
|
||||
apiKey, err = uc.apiKeyGen.GenerateTest() // test_sk_...
|
||||
uc.logger.Info("generating test API key for development")
|
||||
} else {
|
||||
apiKey, err = uc.apiKeyGen.Generate() // live_sk_...
|
||||
}
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to generate API key", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to generate API key: %w", err)
|
||||
}
|
||||
|
||||
// Hash API key
|
||||
apiKeyHash := uc.apiKeyHasher.Hash(apiKey)
|
||||
apiKeyPrefix := apikey.ExtractPrefix(apiKey)
|
||||
apiKeyLastFour := apikey.ExtractLastFour(apiKey)
|
||||
|
||||
uc.logger.Info("API key generated",
|
||||
zap.String("prefix", apiKeyPrefix),
|
||||
zap.String("last_four", apiKeyLastFour))
|
||||
|
||||
return &APIKeyResult{
|
||||
PlaintextKey: apiKey,
|
||||
HashedKey: apiKeyHash,
|
||||
Prefix: apiKeyPrefix,
|
||||
LastFour: apiKeyLastFour,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GenerateVerificationTokenUseCase generates a verification token for domain verification
|
||||
type GenerateVerificationTokenUseCase struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGenerateVerificationTokenUseCase creates a new GenerateVerificationTokenUseCase
|
||||
func ProvideGenerateVerificationTokenUseCase(
|
||||
logger *zap.Logger,
|
||||
) *GenerateVerificationTokenUseCase {
|
||||
return &GenerateVerificationTokenUseCase{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute generates a cryptographically secure verification token
|
||||
func (uc *GenerateVerificationTokenUseCase) Execute() (string, error) {
|
||||
b := make([]byte, 16) // 16 bytes = 128 bits
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
uc.logger.Error("failed to generate random bytes", zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(b)
|
||||
verificationToken := "mvp_" + token // mvp = maplepress verify
|
||||
|
||||
uc.logger.Info("verification token generated")
|
||||
return verificationToken, nil
|
||||
}
|
||||
50
cloud/maplepress-backend/internal/usecase/site/get.go
Normal file
50
cloud/maplepress-backend/internal/usecase/site/get.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// GetSiteUseCase handles getting a site by ID
|
||||
type GetSiteUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetSiteUseCase creates a new GetSiteUseCase
|
||||
func ProvideGetSiteUseCase(repo domainsite.Repository, logger *zap.Logger) *GetSiteUseCase {
|
||||
return &GetSiteUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSiteInput is the input for getting a site
|
||||
type GetSiteInput struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// GetSiteOutput is the output after getting a site
|
||||
type GetSiteOutput struct {
|
||||
Site *domainsite.Site
|
||||
}
|
||||
|
||||
// Execute gets a site by ID
|
||||
func (uc *GetSiteUseCase) Execute(ctx context.Context, tenantID gocql.UUID, input *GetSiteInput) (*GetSiteOutput, error) {
|
||||
siteID, err := gocql.ParseUUID(input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
site, err := uc.repo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GetSiteOutput{Site: site}, nil
|
||||
}
|
||||
55
cloud/maplepress-backend/internal/usecase/site/list.go
Normal file
55
cloud/maplepress-backend/internal/usecase/site/list.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ListSitesUseCase handles listing sites for a tenant
|
||||
type ListSitesUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideListSitesUseCase creates a new ListSitesUseCase
|
||||
func ProvideListSitesUseCase(repo domainsite.Repository, logger *zap.Logger) *ListSitesUseCase {
|
||||
return &ListSitesUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListSitesInput is the input for listing sites
|
||||
type ListSitesInput struct {
|
||||
PageSize int
|
||||
PageState []byte
|
||||
}
|
||||
|
||||
// ListSitesOutput is the output after listing sites
|
||||
type ListSitesOutput struct {
|
||||
Sites []*domainsite.Site
|
||||
PageState []byte
|
||||
}
|
||||
|
||||
// Execute lists all sites for a tenant
|
||||
func (uc *ListSitesUseCase) Execute(ctx context.Context, tenantID gocql.UUID, input *ListSitesInput) (*ListSitesOutput, error) {
|
||||
pageSize := input.PageSize
|
||||
if pageSize == 0 {
|
||||
pageSize = 20 // Default page size
|
||||
}
|
||||
|
||||
sites, nextPageState, err := uc.repo.ListByTenant(ctx, tenantID, pageSize, input.PageState)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to list sites", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListSitesOutput{
|
||||
Sites: sites,
|
||||
PageState: nextPageState,
|
||||
}, nil
|
||||
}
|
||||
127
cloud/maplepress-backend/internal/usecase/site/reset_usage.go
Normal file
127
cloud/maplepress-backend/internal/usecase/site/reset_usage.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ResetMonthlyUsageUseCase handles resetting monthly usage counters for all sites (for billing cycles)
|
||||
type ResetMonthlyUsageUseCase struct {
|
||||
siteRepo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideResetMonthlyUsageUseCase creates a new ResetMonthlyUsageUseCase
|
||||
func ProvideResetMonthlyUsageUseCase(
|
||||
siteRepo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ResetMonthlyUsageUseCase {
|
||||
return &ResetMonthlyUsageUseCase{
|
||||
siteRepo: siteRepo,
|
||||
logger: logger.Named("reset-monthly-usage-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// ResetUsageOutput is the output after resetting usage counters
|
||||
type ResetUsageOutput struct {
|
||||
ProcessedSites int `json:"processed_sites"`
|
||||
ResetCount int `json:"reset_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
ProcessedAt time.Time `json:"processed_at"`
|
||||
}
|
||||
|
||||
// Execute resets monthly usage counters for all sites (for billing cycles)
|
||||
func (uc *ResetMonthlyUsageUseCase) Execute(ctx context.Context) (*ResetUsageOutput, error) {
|
||||
uc.logger.Info("starting monthly usage counter reset for all sites")
|
||||
|
||||
startTime := time.Now()
|
||||
processedSites := 0
|
||||
resetCount := 0
|
||||
failedCount := 0
|
||||
|
||||
// Pagination settings
|
||||
const pageSize = 100
|
||||
var pageState []byte
|
||||
|
||||
// Iterate through all sites using pagination
|
||||
for {
|
||||
// Get a batch of sites
|
||||
sites, nextPageState, err := uc.siteRepo.GetAllSitesForUsageReset(ctx, pageSize, pageState)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get sites for usage reset", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get sites: %w", err)
|
||||
}
|
||||
|
||||
// Process each site in the batch
|
||||
for _, site := range sites {
|
||||
processedSites++
|
||||
|
||||
// Check if usage needs to be reset (monthly billing cycle)
|
||||
now := time.Now()
|
||||
needsReset := false
|
||||
|
||||
// Check if it's been a month since last reset
|
||||
if site.LastResetAt.AddDate(0, 1, 0).Before(now) {
|
||||
needsReset = true
|
||||
}
|
||||
|
||||
if !needsReset {
|
||||
uc.logger.Debug("site usage not due for reset",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain),
|
||||
zap.Time("last_reset_at", site.LastResetAt))
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset the usage counters
|
||||
site.ResetMonthlyUsage()
|
||||
|
||||
// Update the site in database
|
||||
if err := uc.siteRepo.UpdateUsage(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to reset usage for site",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain),
|
||||
zap.Error(err))
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
resetCount++
|
||||
uc.logger.Debug("reset usage for site",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain),
|
||||
zap.Time("last_reset_at", site.LastResetAt))
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
if len(nextPageState) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pageState = nextPageState
|
||||
|
||||
uc.logger.Info("processed batch of sites",
|
||||
zap.Int("batch_size", len(sites)),
|
||||
zap.Int("total_processed", processedSites),
|
||||
zap.Int("reset_count", resetCount),
|
||||
zap.Int("failed_count", failedCount))
|
||||
}
|
||||
|
||||
uc.logger.Info("monthly usage counter reset completed",
|
||||
zap.Int("processed_sites", processedSites),
|
||||
zap.Int("reset_count", resetCount),
|
||||
zap.Int("failed_count", failedCount),
|
||||
zap.Duration("duration", time.Since(startTime)))
|
||||
|
||||
return &ResetUsageOutput{
|
||||
ProcessedSites: processedSites,
|
||||
ResetCount: resetCount,
|
||||
FailedCount: failedCount,
|
||||
ProcessedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
106
cloud/maplepress-backend/internal/usecase/site/rotate_apikey.go
Normal file
106
cloud/maplepress-backend/internal/usecase/site/rotate_apikey.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/apikey"
|
||||
)
|
||||
|
||||
// RotateAPIKeyUseCase handles API key rotation
|
||||
// DEPRECATED: This usecase is too fat and violates Clean Architecture.
|
||||
// Use the service layer (service/site/rotate_apikey.go) which orchestrates
|
||||
// focused usecases: GetSiteUseCase, GenerateAPIKeyUseCase, UpdateSiteAPIKeyUseCase, UpdateSiteToRepoUseCase.
|
||||
// This will be removed after migration is complete.
|
||||
type RotateAPIKeyUseCase struct {
|
||||
repo domainsite.Repository
|
||||
apiKeyGen apikey.Generator
|
||||
apiKeyHasher apikey.Hasher
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideRotateAPIKeyUseCase creates a new RotateAPIKeyUseCase
|
||||
func ProvideRotateAPIKeyUseCase(
|
||||
repo domainsite.Repository,
|
||||
apiKeyGen apikey.Generator,
|
||||
apiKeyHasher apikey.Hasher,
|
||||
logger *zap.Logger,
|
||||
) *RotateAPIKeyUseCase {
|
||||
return &RotateAPIKeyUseCase{
|
||||
repo: repo,
|
||||
apiKeyGen: apiKeyGen,
|
||||
apiKeyHasher: apiKeyHasher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RotateAPIKeyInput is the input for rotating an API key
|
||||
type RotateAPIKeyInput struct {
|
||||
SiteID string
|
||||
}
|
||||
|
||||
// RotateAPIKeyOutput is the output after rotating an API key
|
||||
type RotateAPIKeyOutput struct {
|
||||
NewAPIKey string `json:"new_api_key"`
|
||||
OldKeyLastFour string `json:"old_key_last_four"`
|
||||
RotatedAt time.Time `json:"rotated_at"`
|
||||
}
|
||||
|
||||
// Execute rotates a site's API key
|
||||
func (uc *RotateAPIKeyUseCase) Execute(ctx context.Context, tenantID gocql.UUID, input *RotateAPIKeyInput) (*RotateAPIKeyOutput, error) {
|
||||
siteID, err := gocql.ParseUUID(input.SiteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current site
|
||||
site, err := uc.repo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store old key info
|
||||
oldKeyLastFour := site.APIKeyLastFour
|
||||
|
||||
// Generate new API key
|
||||
newAPIKey, err := uc.apiKeyGen.Generate()
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to generate new API key", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to generate API key: %w", err)
|
||||
}
|
||||
|
||||
// Hash new key
|
||||
newKeyHash := uc.apiKeyHasher.Hash(newAPIKey)
|
||||
newKeyPrefix := apikey.ExtractPrefix(newAPIKey)
|
||||
newKeyLastFour := apikey.ExtractLastFour(newAPIKey)
|
||||
|
||||
// Update site with new key
|
||||
site.APIKeyHash = newKeyHash
|
||||
site.APIKeyPrefix = newKeyPrefix
|
||||
site.APIKeyLastFour = newKeyLastFour
|
||||
site.UpdatedAt = time.Now()
|
||||
|
||||
// Update in repository (all 4 tables)
|
||||
if err := uc.repo.Update(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to update site with new API key", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rotatedAt := time.Now()
|
||||
|
||||
uc.logger.Info("API key rotated successfully",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("old_key_last_four", oldKeyLastFour))
|
||||
|
||||
return &RotateAPIKeyOutput{
|
||||
NewAPIKey: newAPIKey, // PLAINTEXT - only shown once!
|
||||
OldKeyLastFour: oldKeyLastFour,
|
||||
RotatedAt: rotatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// SaveSiteToRepoUseCase saves a site to the repository
|
||||
type SaveSiteToRepoUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSaveSiteToRepoUseCase creates a new SaveSiteToRepoUseCase
|
||||
func ProvideSaveSiteToRepoUseCase(
|
||||
repo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *SaveSiteToRepoUseCase {
|
||||
return &SaveSiteToRepoUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute saves a site to the repository (writes to all 4 Cassandra tables)
|
||||
func (uc *SaveSiteToRepoUseCase) Execute(ctx context.Context, site *domainsite.Site) error {
|
||||
if err := uc.repo.Create(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to create site in repository",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to create site: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("site saved to repository",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// UpdateSiteAPIKeyUseCase updates a site entity with new API key details
|
||||
type UpdateSiteAPIKeyUseCase struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideUpdateSiteAPIKeyUseCase creates a new UpdateSiteAPIKeyUseCase
|
||||
func ProvideUpdateSiteAPIKeyUseCase(logger *zap.Logger) *UpdateSiteAPIKeyUseCase {
|
||||
return &UpdateSiteAPIKeyUseCase{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSiteAPIKeyInput contains the new API key details
|
||||
type UpdateSiteAPIKeyInput struct {
|
||||
Site *domainsite.Site
|
||||
NewAPIKeyHash string
|
||||
NewKeyPrefix string
|
||||
NewKeyLastFour string
|
||||
}
|
||||
|
||||
// Execute updates the site entity with new API key details
|
||||
func (uc *UpdateSiteAPIKeyUseCase) Execute(input *UpdateSiteAPIKeyInput) {
|
||||
input.Site.APIKeyHash = input.NewAPIKeyHash
|
||||
input.Site.APIKeyPrefix = input.NewKeyPrefix
|
||||
input.Site.APIKeyLastFour = input.NewKeyLastFour
|
||||
input.Site.UpdatedAt = time.Now()
|
||||
|
||||
uc.logger.Debug("site entity updated with new API key",
|
||||
zap.String("site_id", input.Site.ID.String()),
|
||||
zap.String("new_prefix", input.NewKeyPrefix),
|
||||
zap.String("new_last_four", input.NewKeyLastFour))
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// UpdateSiteAPIKeyToRepoInput defines the input for updating a site's API key in the repository
|
||||
type UpdateSiteAPIKeyToRepoInput struct {
|
||||
Site *domainsite.Site
|
||||
OldAPIKeyHash string
|
||||
}
|
||||
|
||||
// UpdateSiteAPIKeyToRepoUseCase updates a site's API key in the repository (all tables)
|
||||
// This use case properly handles the sites_by_apikey table by deleting the old entry
|
||||
// and inserting a new one, since api_key_hash is part of the primary key
|
||||
type UpdateSiteAPIKeyToRepoUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUpdateSiteAPIKeyToRepoUseCase creates a new UpdateSiteAPIKeyToRepoUseCase
|
||||
func NewUpdateSiteAPIKeyToRepoUseCase(
|
||||
repo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *UpdateSiteAPIKeyToRepoUseCase {
|
||||
return &UpdateSiteAPIKeyToRepoUseCase{
|
||||
repo: repo,
|
||||
logger: logger.Named("update-site-apikey-to-repo-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideUpdateSiteAPIKeyToRepoUseCase creates a new UpdateSiteAPIKeyToRepoUseCase for dependency injection
|
||||
func ProvideUpdateSiteAPIKeyToRepoUseCase(
|
||||
repo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *UpdateSiteAPIKeyToRepoUseCase {
|
||||
return NewUpdateSiteAPIKeyToRepoUseCase(repo, logger)
|
||||
}
|
||||
|
||||
// Execute updates a site's API key in the repository (all tables)
|
||||
func (uc *UpdateSiteAPIKeyToRepoUseCase) Execute(ctx context.Context, input *UpdateSiteAPIKeyToRepoInput) error {
|
||||
if err := uc.repo.UpdateAPIKey(ctx, input.Site, input.OldAPIKeyHash); err != nil {
|
||||
uc.logger.Error("failed to update site API key in repository",
|
||||
zap.String("site_id", input.Site.ID.String()),
|
||||
zap.String("old_key_hash", input.OldAPIKeyHash),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to update site API key: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("site API key updated in repository",
|
||||
zap.String("site_id", input.Site.ID.String()),
|
||||
zap.String("domain", input.Site.Domain),
|
||||
zap.String("new_key_prefix", input.Site.APIKeyPrefix),
|
||||
zap.String("new_key_last_four", input.Site.APIKeyLastFour))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// UpdateSiteToRepoUseCase updates a site in the repository
|
||||
type UpdateSiteToRepoUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideUpdateSiteToRepoUseCase creates a new UpdateSiteToRepoUseCase
|
||||
func ProvideUpdateSiteToRepoUseCase(
|
||||
repo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *UpdateSiteToRepoUseCase {
|
||||
return &UpdateSiteToRepoUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute updates a site in the repository (all tables)
|
||||
func (uc *UpdateSiteToRepoUseCase) Execute(ctx context.Context, site *domainsite.Site) error {
|
||||
if err := uc.repo.Update(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to update site in repository",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to update site: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("site updated in repository",
|
||||
zap.String("site_id", site.ID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ValidateDomainUseCase checks if a domain is available for registration
|
||||
type ValidateDomainUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateDomainUseCase creates a new ValidateDomainUseCase
|
||||
func ProvideValidateDomainUseCase(
|
||||
repo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateDomainUseCase {
|
||||
return &ValidateDomainUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates if a domain can be used for a new site
|
||||
func (uc *ValidateDomainUseCase) Execute(ctx context.Context, domain string) error {
|
||||
// Check if domain already exists
|
||||
exists, err := uc.repo.DomainExists(ctx, domain)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to check domain existence",
|
||||
zap.String("domain", domain),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
uc.logger.Warn("domain already exists", zap.String("domain", domain))
|
||||
return domainsite.ErrDomainAlreadyExists
|
||||
}
|
||||
|
||||
uc.logger.Info("domain is available", zap.String("domain", domain))
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
)
|
||||
|
||||
// ValidateSiteForDeletionUseCase validates that a site exists and can be deleted
|
||||
type ValidateSiteForDeletionUseCase struct {
|
||||
repo domainsite.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateSiteForDeletionUseCase creates a new ValidateSiteForDeletionUseCase
|
||||
func ProvideValidateSiteForDeletionUseCase(
|
||||
repo domainsite.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateSiteForDeletionUseCase {
|
||||
return &ValidateSiteForDeletionUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates that a site exists before deletion
|
||||
func (uc *ValidateSiteForDeletionUseCase) Execute(ctx context.Context, tenantID, siteID gocql.UUID) (*domainsite.Site, error) {
|
||||
site, err := uc.repo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("site not found for deletion",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uc.logger.Debug("site validated for deletion",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
|
||||
return site, nil
|
||||
}
|
||||
132
cloud/maplepress-backend/internal/usecase/site/verify.go
Normal file
132
cloud/maplepress-backend/internal/usecase/site/verify.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/dns"
|
||||
)
|
||||
|
||||
// VerifySiteUseCase handles site verification business logic
|
||||
type VerifySiteUseCase struct {
|
||||
repo domainsite.Repository
|
||||
dnsVerifier *dns.Verifier
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideVerifySiteUseCase creates a new VerifySiteUseCase
|
||||
func ProvideVerifySiteUseCase(
|
||||
repo domainsite.Repository,
|
||||
dnsVerifier *dns.Verifier,
|
||||
logger *zap.Logger,
|
||||
) *VerifySiteUseCase {
|
||||
return &VerifySiteUseCase{
|
||||
repo: repo,
|
||||
dnsVerifier: dnsVerifier,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifySiteInput is the input for verifying a site
|
||||
// No input fields needed - verification is done via DNS TXT record lookup
|
||||
type VerifySiteInput struct {
|
||||
// Empty struct - DNS verification uses the token stored in the site entity
|
||||
}
|
||||
|
||||
// VerifySiteOutput is the output after verifying a site
|
||||
type VerifySiteOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Execute verifies a site using the verification token
|
||||
func (uc *VerifySiteUseCase) Execute(
|
||||
ctx context.Context,
|
||||
tenantID gocql.UUID,
|
||||
siteID gocql.UUID,
|
||||
input *VerifySiteInput,
|
||||
) (*VerifySiteOutput, error) {
|
||||
uc.logger.Info("executing verify site use case via DNS",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()))
|
||||
|
||||
// Get site from repository
|
||||
site, err := uc.repo.GetByID(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get site", zap.Error(err))
|
||||
return nil, domainsite.ErrSiteNotFound
|
||||
}
|
||||
|
||||
// Check if site is already verified
|
||||
if site.IsVerified {
|
||||
uc.logger.Info("site already verified",
|
||||
zap.String("site_id", siteID.String()))
|
||||
return &VerifySiteOutput{
|
||||
Success: true,
|
||||
Status: site.Status,
|
||||
Message: "Site is already verified",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Test mode sites don't need verification
|
||||
if site.IsTestMode() {
|
||||
uc.logger.Info("test mode site, skipping DNS verification",
|
||||
zap.String("site_id", siteID.String()))
|
||||
site.Verify()
|
||||
if err := uc.repo.Update(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to update site", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to update site: %w", err)
|
||||
}
|
||||
return &VerifySiteOutput{
|
||||
Success: true,
|
||||
Status: site.Status,
|
||||
Message: "Test mode site verified successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Perform DNS TXT record verification
|
||||
uc.logger.Info("performing DNS verification",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("domain", site.Domain),
|
||||
zap.String("expected_token", site.VerificationToken))
|
||||
|
||||
verified, err := uc.dnsVerifier.VerifyDomainOwnership(ctx, site.Domain, site.VerificationToken)
|
||||
if err != nil {
|
||||
uc.logger.Error("DNS verification failed",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("domain", site.Domain),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("DNS verification failed: %w", err)
|
||||
}
|
||||
|
||||
if !verified {
|
||||
uc.logger.Warn("DNS verification record not found",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
return nil, fmt.Errorf("DNS TXT record not found. Please add the verification record to your domain's DNS settings")
|
||||
}
|
||||
|
||||
// DNS verification successful - mark site as verified
|
||||
site.Verify()
|
||||
|
||||
// Update in repository
|
||||
if err := uc.repo.Update(ctx, site); err != nil {
|
||||
uc.logger.Error("failed to update site", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to update site: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("site verified successfully via DNS",
|
||||
zap.String("site_id", siteID.String()),
|
||||
zap.String("domain", site.Domain))
|
||||
|
||||
return &VerifySiteOutput{
|
||||
Success: true,
|
||||
Status: site.Status,
|
||||
Message: "Domain ownership verified successfully via DNS TXT record",
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domaintenant "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
|
||||
)
|
||||
|
||||
// CreateTenantInput represents the input for creating a tenant
|
||||
type CreateTenantInput struct {
|
||||
Name string
|
||||
Slug string
|
||||
CreatedFromIPAddress string // Plain IP address (will be encrypted before storage)
|
||||
}
|
||||
|
||||
// CreateTenantOutput represents the output after creating a tenant
|
||||
type CreateTenantOutput struct {
|
||||
ID string
|
||||
Name string
|
||||
Slug string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateTenantEntityUseCase creates and validates a tenant domain entity
|
||||
type CreateTenantEntityUseCase struct {
|
||||
ipEncryptor *ipcrypt.IPEncryptor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreateTenantEntityUseCase creates a new CreateTenantEntityUseCase
|
||||
func ProvideCreateTenantEntityUseCase(
|
||||
ipEncryptor *ipcrypt.IPEncryptor,
|
||||
logger *zap.Logger,
|
||||
) *CreateTenantEntityUseCase {
|
||||
return &CreateTenantEntityUseCase{
|
||||
ipEncryptor: ipEncryptor,
|
||||
logger: logger.Named("create-tenant-entity-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute creates a new tenant domain entity with validation
|
||||
func (uc *CreateTenantEntityUseCase) Execute(input *CreateTenantInput) (*domaintenant.Tenant, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Encrypt IP address (CWE-359: GDPR compliance)
|
||||
encryptedIP, err := uc.ipEncryptor.Encrypt(input.CreatedFromIPAddress)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to encrypt IP address",
|
||||
zap.String("slug", input.Slug),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create domain entity
|
||||
tenant := &domaintenant.Tenant{
|
||||
ID: gocql.TimeUUID().String(),
|
||||
Name: input.Name,
|
||||
Slug: input.Slug,
|
||||
Status: domaintenant.StatusActive,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
// CWE-359: Encrypted IP address tracking for GDPR compliance
|
||||
CreatedFromIPAddress: encryptedIP,
|
||||
CreatedFromIPTimestamp: now,
|
||||
ModifiedFromIPAddress: encryptedIP,
|
||||
ModifiedFromIPTimestamp: now,
|
||||
}
|
||||
|
||||
// Validate domain entity
|
||||
if err := tenant.Validate(); err != nil {
|
||||
uc.logger.Warn("tenant validation failed",
|
||||
zap.String("slug", input.Slug),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uc.logger.Debug("tenant entity created and validated",
|
||||
zap.String("tenant_id", tenant.ID),
|
||||
zap.String("slug", tenant.Slug))
|
||||
|
||||
return tenant, nil
|
||||
}
|
||||
60
cloud/maplepress-backend/internal/usecase/tenant/delete.go
Normal file
60
cloud/maplepress-backend/internal/usecase/tenant/delete.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
|
||||
)
|
||||
|
||||
// DeleteTenantUseCase handles tenant deletion operations
|
||||
// Used as compensating transaction in SAGA pattern
|
||||
type DeleteTenantUseCase struct {
|
||||
repo tenant.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeleteTenantUseCase creates a new DeleteTenantUseCase for dependency injection
|
||||
func ProvideDeleteTenantUseCase(
|
||||
repo tenant.Repository,
|
||||
logger *zap.Logger,
|
||||
) *DeleteTenantUseCase {
|
||||
return &DeleteTenantUseCase{
|
||||
repo: repo,
|
||||
logger: logger.Named("delete-tenant-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute deletes a tenant by ID
|
||||
// This is used as a compensating transaction when registration fails
|
||||
//
|
||||
// IMPORTANT: This operation must be idempotent!
|
||||
// If called multiple times with the same ID, it should not error
|
||||
func (uc *DeleteTenantUseCase) Execute(ctx context.Context, tenantID string) error {
|
||||
uc.logger.Info("deleting tenant",
|
||||
zap.String("tenant_id", tenantID))
|
||||
|
||||
// Validate input
|
||||
if tenantID == "" {
|
||||
return fmt.Errorf("tenant ID cannot be empty")
|
||||
}
|
||||
|
||||
// Execute deletion using existing repository method
|
||||
// The repository handles deletion from all denormalized tables:
|
||||
// - tenants_by_id
|
||||
// - tenants_by_slug
|
||||
// - tenants_by_status
|
||||
if err := uc.repo.Delete(ctx, tenantID); err != nil {
|
||||
uc.logger.Error("failed to delete tenant",
|
||||
zap.String("tenant_id", tenantID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to delete tenant: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("tenant deleted successfully",
|
||||
zap.String("tenant_id", tenantID))
|
||||
|
||||
return nil
|
||||
}
|
||||
72
cloud/maplepress-backend/internal/usecase/tenant/get.go
Normal file
72
cloud/maplepress-backend/internal/usecase/tenant/get.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
domaintenant "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
|
||||
)
|
||||
|
||||
// GetTenantInput represents the input for getting a tenant
|
||||
type GetTenantInput struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// GetTenantBySlugInput represents the input for getting a tenant by slug
|
||||
type GetTenantBySlugInput struct {
|
||||
Slug string
|
||||
}
|
||||
|
||||
// GetTenantOutput represents the output after getting a tenant
|
||||
type GetTenantOutput struct {
|
||||
ID string
|
||||
Name string
|
||||
Slug string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetTenantUseCase handles tenant retrieval business logic
|
||||
type GetTenantUseCase struct {
|
||||
repo domaintenant.Repository
|
||||
}
|
||||
|
||||
// ProvideGetTenantUseCase creates a new GetTenantUseCase
|
||||
func ProvideGetTenantUseCase(repo domaintenant.Repository) *GetTenantUseCase {
|
||||
return &GetTenantUseCase{repo: repo}
|
||||
}
|
||||
|
||||
// Execute retrieves a tenant by ID
|
||||
func (uc *GetTenantUseCase) Execute(ctx context.Context, input *GetTenantInput) (*GetTenantOutput, error) {
|
||||
tenant, err := uc.repo.GetByID(ctx, input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GetTenantOutput{
|
||||
ID: tenant.ID,
|
||||
Name: tenant.Name,
|
||||
Slug: tenant.Slug,
|
||||
Status: string(tenant.Status),
|
||||
CreatedAt: tenant.CreatedAt,
|
||||
UpdatedAt: tenant.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExecuteBySlug retrieves a tenant by slug
|
||||
func (uc *GetTenantUseCase) ExecuteBySlug(ctx context.Context, input *GetTenantBySlugInput) (*GetTenantOutput, error) {
|
||||
tenant, err := uc.repo.GetBySlug(ctx, input.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GetTenantOutput{
|
||||
ID: tenant.ID,
|
||||
Name: tenant.Name,
|
||||
Slug: tenant.Slug,
|
||||
Status: string(tenant.Status),
|
||||
CreatedAt: tenant.CreatedAt,
|
||||
UpdatedAt: tenant.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domaintenant "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
|
||||
)
|
||||
|
||||
// SaveTenantToRepoUseCase saves a tenant to the repository
|
||||
type SaveTenantToRepoUseCase struct {
|
||||
repo domaintenant.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSaveTenantToRepoUseCase creates a new SaveTenantToRepoUseCase
|
||||
func ProvideSaveTenantToRepoUseCase(
|
||||
repo domaintenant.Repository,
|
||||
logger *zap.Logger,
|
||||
) *SaveTenantToRepoUseCase {
|
||||
return &SaveTenantToRepoUseCase{
|
||||
repo: repo,
|
||||
logger: logger.Named("save-tenant-to-repo-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute saves a tenant to the repository
|
||||
func (uc *SaveTenantToRepoUseCase) Execute(ctx context.Context, tenant *domaintenant.Tenant) error {
|
||||
if err := uc.repo.Create(ctx, tenant); err != nil {
|
||||
uc.logger.Error("failed to create tenant in repository",
|
||||
zap.String("tenant_id", tenant.ID),
|
||||
zap.String("slug", tenant.Slug),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to create tenant: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("tenant saved to repository",
|
||||
zap.String("tenant_id", tenant.ID),
|
||||
zap.String("slug", tenant.Slug))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domaintenant "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
// ValidateTenantSlugUniqueUseCase validates that a tenant slug is unique
|
||||
type ValidateTenantSlugUniqueUseCase struct {
|
||||
repo domaintenant.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateTenantSlugUniqueUseCase creates a new ValidateTenantSlugUniqueUseCase
|
||||
func ProvideValidateTenantSlugUniqueUseCase(
|
||||
repo domaintenant.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateTenantSlugUniqueUseCase {
|
||||
return &ValidateTenantSlugUniqueUseCase{
|
||||
repo: repo,
|
||||
logger: logger.Named("validate-tenant-slug-unique-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates that a tenant slug is unique (not already taken)
|
||||
func (uc *ValidateTenantSlugUniqueUseCase) Execute(ctx context.Context, slug string) error {
|
||||
existing, err := uc.repo.GetBySlug(ctx, slug)
|
||||
if err == nil && existing != nil {
|
||||
// CWE-532: Use redacted tenant slug for logging
|
||||
uc.logger.Warn("tenant slug already exists",
|
||||
logger.TenantSlugHash(slug),
|
||||
logger.SafeTenantSlug("tenant_slug_redacted", slug))
|
||||
return domaintenant.ErrTenantExists
|
||||
}
|
||||
|
||||
// Ignore "not found" error (expected case - slug is available)
|
||||
if err != nil && err != domaintenant.ErrTenantNotFound {
|
||||
uc.logger.Error("failed to check tenant slug uniqueness", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// CWE-532: Use redacted tenant slug for logging
|
||||
uc.logger.Debug("tenant slug is unique",
|
||||
logger.TenantSlugHash(slug),
|
||||
logger.SafeTenantSlug("tenant_slug_redacted", slug))
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainuser "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
|
||||
)
|
||||
|
||||
// CreateUserEntityUseCase creates and validates a user domain entity
|
||||
type CreateUserEntityUseCase struct {
|
||||
ipEncryptor *ipcrypt.IPEncryptor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreateUserEntityUseCase creates a new CreateUserEntityUseCase
|
||||
func ProvideCreateUserEntityUseCase(ipEncryptor *ipcrypt.IPEncryptor, logger *zap.Logger) *CreateUserEntityUseCase {
|
||||
return &CreateUserEntityUseCase{
|
||||
ipEncryptor: ipEncryptor,
|
||||
logger: logger.Named("create-user-entity-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute creates a new user domain entity with validation
|
||||
func (uc *CreateUserEntityUseCase) Execute(tenantID string, input *CreateUserInput) (*domainuser.User, error) {
|
||||
// Set default role if not provided
|
||||
role := int(input.Role)
|
||||
if role == 0 {
|
||||
role = 1 // Default role
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// CWE-359: Encrypt IP address for GDPR compliance
|
||||
encryptedIP := ""
|
||||
if input.CreatedFromIPAddress != "" {
|
||||
encrypted, err := uc.ipEncryptor.Encrypt(input.CreatedFromIPAddress)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to encrypt IP address",
|
||||
zap.Error(err),
|
||||
zap.String("ip_plain", input.CreatedFromIPAddress))
|
||||
// Don't fail user creation if encryption fails, just log it
|
||||
encryptedIP = ""
|
||||
} else {
|
||||
encryptedIP = encrypted
|
||||
uc.logger.Debug("IP address encrypted for user creation")
|
||||
}
|
||||
}
|
||||
|
||||
// Create domain entity
|
||||
user := &domainuser.User{
|
||||
ID: gocql.TimeUUID().String(),
|
||||
TenantID: tenantID,
|
||||
Email: input.Email,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Name: input.FirstName + " " + input.LastName, // Computed from FirstName + LastName
|
||||
Role: role,
|
||||
Status: 1, // Default active status
|
||||
|
||||
ProfileData: &domainuser.UserProfileData{
|
||||
AgreeTermsOfService: true, // Default to true for entity creation
|
||||
},
|
||||
|
||||
SecurityData: &domainuser.UserSecurityData{
|
||||
PasswordHash: input.PasswordHash,
|
||||
PasswordHashAlgorithm: "argon2id",
|
||||
WasEmailVerified: false,
|
||||
},
|
||||
|
||||
Metadata: &domainuser.UserMetadata{
|
||||
CreatedFromIPAddress: encryptedIP, // CWE-359: Encrypted IP
|
||||
CreatedFromIPTimestamp: now, // CWE-359: For 90-day GDPR expiration
|
||||
ModifiedFromIPAddress: encryptedIP, // CWE-359: Encrypted IP
|
||||
ModifiedFromIPTimestamp: now, // CWE-359: For 90-day GDPR expiration
|
||||
CreatedAt: now,
|
||||
ModifiedAt: now,
|
||||
},
|
||||
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Validate domain entity
|
||||
if err := user.Validate(); err != nil {
|
||||
// CWE-532: Use hashed email to prevent PII in logs
|
||||
uc.logger.Warn("user validation failed",
|
||||
logger.EmailHash(input.Email),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// CWE-532: Use hashed email to prevent PII in logs
|
||||
uc.logger.Debug("user entity created and validated",
|
||||
zap.String("user_id", user.ID),
|
||||
logger.EmailHash(user.Email),
|
||||
zap.Int("role", user.Role))
|
||||
|
||||
return user, nil
|
||||
}
|
||||
61
cloud/maplepress-backend/internal/usecase/user/delete.go
Normal file
61
cloud/maplepress-backend/internal/usecase/user/delete.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
|
||||
)
|
||||
|
||||
// DeleteUserUseCase handles user deletion operations
|
||||
// Used as compensating transaction in SAGA pattern
|
||||
type DeleteUserUseCase struct {
|
||||
repo user.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeleteUserUseCase creates a new DeleteUserUseCase for dependency injection
|
||||
func ProvideDeleteUserUseCase(
|
||||
repo user.Repository,
|
||||
logger *zap.Logger,
|
||||
) *DeleteUserUseCase {
|
||||
return &DeleteUserUseCase{
|
||||
repo: repo,
|
||||
logger: logger.Named("delete-user-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute deletes a user by ID within a tenant
|
||||
// This is used as a compensating transaction
|
||||
//
|
||||
// IMPORTANT: This operation must be idempotent!
|
||||
func (uc *DeleteUserUseCase) Execute(ctx context.Context, tenantID, userID string) error {
|
||||
uc.logger.Info("deleting user",
|
||||
zap.String("tenant_id", tenantID),
|
||||
zap.String("user_id", userID))
|
||||
|
||||
// Validate inputs
|
||||
if tenantID == "" {
|
||||
return fmt.Errorf("tenant ID cannot be empty")
|
||||
}
|
||||
if userID == "" {
|
||||
return fmt.Errorf("user ID cannot be empty")
|
||||
}
|
||||
|
||||
// Execute deletion using repository
|
||||
if err := uc.repo.Delete(ctx, tenantID, userID); err != nil {
|
||||
uc.logger.Error("failed to delete user",
|
||||
zap.String("tenant_id", tenantID),
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("user deleted successfully",
|
||||
zap.String("tenant_id", tenantID),
|
||||
zap.String("user_id", userID))
|
||||
|
||||
return nil
|
||||
}
|
||||
59
cloud/maplepress-backend/internal/usecase/user/get.go
Normal file
59
cloud/maplepress-backend/internal/usecase/user/get.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainuser "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
|
||||
)
|
||||
|
||||
// GetUserUseCase handles retrieving a user by ID
|
||||
type GetUserUseCase struct {
|
||||
repo domainuser.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetUserUseCase creates a new GetUserUseCase
|
||||
func ProvideGetUserUseCase(repo domainuser.Repository, logger *zap.Logger) *GetUserUseCase {
|
||||
return &GetUserUseCase{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserInput is the input for getting a user
|
||||
type GetUserInput struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// GetUserOutput is the output after getting a user
|
||||
type GetUserOutput struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Execute retrieves a user by ID
|
||||
func (uc *GetUserUseCase) Execute(ctx context.Context, tenantID string, input *GetUserInput) (*GetUserOutput, error) {
|
||||
uc.logger.Debug("executing get user use case",
|
||||
zap.String("tenant_id", tenantID),
|
||||
zap.String("id", input.ID))
|
||||
|
||||
user, err := uc.repo.GetByID(ctx, tenantID, input.ID)
|
||||
if err != nil {
|
||||
uc.logger.Error("failed to get user", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GetUserOutput{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainuser "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
|
||||
)
|
||||
|
||||
// SaveUserToRepoUseCase saves a user to the repository
|
||||
type SaveUserToRepoUseCase struct {
|
||||
repo domainuser.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSaveUserToRepoUseCase creates a new SaveUserToRepoUseCase
|
||||
func ProvideSaveUserToRepoUseCase(
|
||||
repo domainuser.Repository,
|
||||
logger *zap.Logger,
|
||||
) *SaveUserToRepoUseCase {
|
||||
return &SaveUserToRepoUseCase{
|
||||
repo: repo,
|
||||
logger: logger.Named("save-user-to-repo-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute saves a user to the repository
|
||||
func (uc *SaveUserToRepoUseCase) Execute(ctx context.Context, tenantID string, user *domainuser.User) error {
|
||||
if err := uc.repo.Create(ctx, tenantID, user); err != nil {
|
||||
uc.logger.Error("failed to create user in repository",
|
||||
zap.String("user_id", user.ID),
|
||||
zap.String("email", user.Email),
|
||||
zap.String("tenant_id", tenantID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
uc.logger.Info("user saved to repository",
|
||||
zap.String("user_id", user.ID),
|
||||
zap.String("email", user.Email),
|
||||
zap.String("tenant_id", tenantID))
|
||||
|
||||
return nil
|
||||
}
|
||||
30
cloud/maplepress-backend/internal/usecase/user/types.go
Normal file
30
cloud/maplepress-backend/internal/usecase/user/types.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package user
|
||||
|
||||
import "time"
|
||||
|
||||
// CreateUserInput is the input for creating a user (IDO - Internal Data Object)
|
||||
type CreateUserInput struct {
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
PasswordHash string // Optional: Hashed password (if creating user with password)
|
||||
PasswordHashAlgorithm string // Algorithm used for password hashing (e.g., "argon2id")
|
||||
Role int // User role (numeric value)
|
||||
Timezone string
|
||||
|
||||
// Consent fields
|
||||
AgreeTermsOfService bool
|
||||
AgreePromotions bool
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool
|
||||
|
||||
// Optional: IP address for audit trail
|
||||
CreatedFromIPAddress string
|
||||
}
|
||||
|
||||
// CreateUserOutput is the output after creating a user (IDO - Internal Data Object)
|
||||
type CreateUserOutput struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainuser "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
// ValidateUserEmailUniqueUseCase validates that a user email is unique within a tenant
|
||||
type ValidateUserEmailUniqueUseCase struct {
|
||||
repo domainuser.Repository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideValidateUserEmailUniqueUseCase creates a new ValidateUserEmailUniqueUseCase
|
||||
func ProvideValidateUserEmailUniqueUseCase(
|
||||
repo domainuser.Repository,
|
||||
logger *zap.Logger,
|
||||
) *ValidateUserEmailUniqueUseCase {
|
||||
return &ValidateUserEmailUniqueUseCase{
|
||||
repo: repo,
|
||||
logger: logger.Named("validate-user-email-unique-usecase"),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates that a user email is unique within a tenant
|
||||
func (uc *ValidateUserEmailUniqueUseCase) Execute(ctx context.Context, tenantID, email string) error {
|
||||
existing, err := uc.repo.GetByEmail(ctx, tenantID, email)
|
||||
if err == nil && existing != nil {
|
||||
// CWE-532: Use redacted email for logging
|
||||
uc.logger.Warn("user email already exists",
|
||||
logger.EmailHash(email),
|
||||
logger.SafeEmail("email_redacted", email),
|
||||
zap.String("tenant_id", tenantID))
|
||||
return domainuser.ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
// Ignore ErrUserNotFound - it's expected (email is available)
|
||||
if err != nil && err != domainuser.ErrUserNotFound {
|
||||
uc.logger.Error("failed to check user email uniqueness", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// CWE-532: Use redacted email for logging
|
||||
uc.logger.Debug("user email is unique",
|
||||
logger.EmailHash(email),
|
||||
logger.SafeEmail("email_redacted", email),
|
||||
zap.String("tenant_id", tenantID))
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue