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

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

View file

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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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"
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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
}

View 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
}

View 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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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
}

View file

@ -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
}

View 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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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
}

View 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
}

View 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
}

View 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
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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
}

View file

@ -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
}

View 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
}

View 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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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
}

View 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
}

View file

@ -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
}

View 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
}

View file

@ -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
}