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,44 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/page/interface.go
package page
import (
"context"
"github.com/gocql/gocql"
)
// Repository defines the interface for page data operations
type Repository interface {
// Create inserts a new page
Create(ctx context.Context, page *Page) error
// Update updates an existing page
Update(ctx context.Context, page *Page) error
// Upsert creates or updates a page
Upsert(ctx context.Context, page *Page) error
// GetByID retrieves a page by site_id and page_id
GetByID(ctx context.Context, siteID gocql.UUID, pageID string) (*Page, error)
// GetBySiteID retrieves all pages for a site
GetBySiteID(ctx context.Context, siteID gocql.UUID) ([]*Page, error)
// GetBySiteIDPaginated retrieves pages for a site with pagination
GetBySiteIDPaginated(ctx context.Context, siteID gocql.UUID, limit int, pageState []byte) ([]*Page, []byte, error)
// Delete deletes a page
Delete(ctx context.Context, siteID gocql.UUID, pageID string) error
// DeleteBySiteID deletes all pages for a site
DeleteBySiteID(ctx context.Context, siteID gocql.UUID) error
// DeleteMultiple deletes multiple pages by their IDs
DeleteMultiple(ctx context.Context, siteID gocql.UUID, pageIDs []string) error
// CountBySiteID counts pages for a site
CountBySiteID(ctx context.Context, siteID gocql.UUID) (int64, error)
// Exists checks if a page exists
Exists(ctx context.Context, siteID gocql.UUID, pageID string) (bool, error)
}

View file

@ -0,0 +1,132 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/page/page.go
package page
import (
"time"
"github.com/gocql/gocql"
)
// Page represents a WordPress page/post indexed in the system
type Page struct {
// Identity
SiteID gocql.UUID `json:"site_id"` // Partition key
PageID string `json:"page_id"` // Clustering key (WordPress page ID)
TenantID gocql.UUID `json:"tenant_id"` // For additional isolation
// Content
Title string `json:"title"`
Content string `json:"content"` // HTML stripped
Excerpt string `json:"excerpt"` // Summary
URL string `json:"url"` // Canonical URL
// Metadata
Status string `json:"status"` // publish, draft, trash
PostType string `json:"post_type"` // page, post
Author string `json:"author"`
// Timestamps
PublishedAt time.Time `json:"published_at"`
ModifiedAt time.Time `json:"modified_at"`
IndexedAt time.Time `json:"indexed_at"` // When we indexed it
// Search
MeilisearchDocID string `json:"meilisearch_doc_id"` // ID in Meilisearch index
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
CreatedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
CreatedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
ModifiedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
ModifiedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
}
// Status constants
const (
StatusPublish = "publish"
StatusDraft = "draft"
StatusTrash = "trash"
)
// PostType constants
const (
PostTypePage = "page"
PostTypePost = "post"
)
// NewPage creates a new Page entity
func NewPage(siteID, tenantID gocql.UUID, pageID string, title, content, excerpt, url, status, postType, author string, publishedAt, modifiedAt time.Time, encryptedIP string) *Page {
now := time.Now()
return &Page{
SiteID: siteID,
PageID: pageID,
TenantID: tenantID,
Title: title,
Content: content,
Excerpt: excerpt,
URL: url,
Status: status,
PostType: postType,
Author: author,
PublishedAt: publishedAt,
ModifiedAt: modifiedAt,
IndexedAt: now,
MeilisearchDocID: "", // Set after indexing in Meilisearch
CreatedAt: now,
UpdatedAt: now,
// CWE-359: Encrypted IP address tracking for GDPR compliance
CreatedFromIPAddress: encryptedIP,
CreatedFromIPTimestamp: now,
ModifiedFromIPAddress: encryptedIP,
ModifiedFromIPTimestamp: now,
}
}
// IsPublished checks if the page is published
func (p *Page) IsPublished() bool {
return p.Status == StatusPublish
}
// ShouldIndex checks if the page should be indexed in search
func (p *Page) ShouldIndex() bool {
// Only index published pages
return p.IsPublished()
}
// GetMeilisearchID returns the Meilisearch document ID
func (p *Page) GetMeilisearchID() string {
if p.MeilisearchDocID != "" {
return p.MeilisearchDocID
}
// Use page_id as fallback
return p.PageID
}
// SetMeilisearchID sets the Meilisearch document ID
func (p *Page) SetMeilisearchID(docID string) {
p.MeilisearchDocID = docID
p.UpdatedAt = time.Now()
}
// MarkIndexed updates the indexed timestamp
func (p *Page) MarkIndexed() {
p.IndexedAt = time.Now()
p.UpdatedAt = time.Now()
}
// Update updates the page content
func (p *Page) Update(title, content, excerpt, url, status, author string, modifiedAt time.Time) {
p.Title = title
p.Content = content
p.Excerpt = excerpt
p.URL = url
p.Status = status
p.Author = author
p.ModifiedAt = modifiedAt
p.UpdatedAt = time.Now()
}

View file

@ -0,0 +1,104 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/securityevent/entity.go
package securityevent
import (
"time"
)
// EventType represents the type of security event
type EventType string
const (
// Account lockout events
EventTypeAccountLocked EventType = "account_locked"
EventTypeAccountUnlocked EventType = "account_unlocked"
// Failed login events
EventTypeFailedLogin EventType = "failed_login"
EventTypeExcessiveFailedLogin EventType = "excessive_failed_login"
// Successful events
EventTypeSuccessfulLogin EventType = "successful_login"
// Rate limiting events
EventTypeIPRateLimitExceeded EventType = "ip_rate_limit_exceeded"
)
// Severity represents the severity level of the security event
type Severity string
const (
SeverityLow Severity = "low"
SeverityMedium Severity = "medium"
SeverityHigh Severity = "high"
SeverityCritical Severity = "critical"
)
// SecurityEvent represents a security-related event in the system
// CWE-778: Insufficient Logging - Security events must be logged for audit
type SecurityEvent struct {
// Unique identifier for the event
ID string `json:"id"`
// Type of security event
EventType EventType `json:"event_type"`
// Severity level
Severity Severity `json:"severity"`
// User email (hashed for privacy)
EmailHash string `json:"email_hash"`
// Client IP address
ClientIP string `json:"client_ip"`
// User agent
UserAgent string `json:"user_agent,omitempty"`
// Additional metadata as key-value pairs
Metadata map[string]interface{} `json:"metadata,omitempty"`
// Timestamp when the event occurred
Timestamp time.Time `json:"timestamp"`
// Message describing the event
Message string `json:"message"`
}
// NewSecurityEvent creates a new security event
func NewSecurityEvent(
eventType EventType,
severity Severity,
emailHash string,
clientIP string,
message string,
) *SecurityEvent {
return &SecurityEvent{
ID: generateEventID(),
EventType: eventType,
Severity: severity,
EmailHash: emailHash,
ClientIP: clientIP,
Metadata: make(map[string]interface{}),
Timestamp: time.Now().UTC(),
Message: message,
}
}
// WithMetadata adds metadata to the security event
func (e *SecurityEvent) WithMetadata(key string, value interface{}) *SecurityEvent {
e.Metadata[key] = value
return e
}
// WithUserAgent sets the user agent
func (e *SecurityEvent) WithUserAgent(userAgent string) *SecurityEvent {
e.UserAgent = userAgent
return e
}
// generateEventID generates a unique event ID
func generateEventID() string {
// Simple timestamp-based ID (can be replaced with UUID if needed)
return time.Now().UTC().Format("20060102150405.000000")
}

View file

@ -0,0 +1,42 @@
package domain
import (
"time"
"github.com/gocql/gocql"
"github.com/google/uuid"
)
// Session represents a user's authentication session
type Session struct {
ID string `json:"id"` // Session UUID
UserID uint64 `json:"user_id"` // User's ID
UserUUID uuid.UUID `json:"user_uuid"` // User's UUID
UserEmail string `json:"user_email"` // User's email
UserName string `json:"user_name"` // User's full name
UserRole string `json:"user_role"` // User's role (admin, user, etc.)
TenantID uuid.UUID `json:"tenant_id"` // Tenant ID for multi-tenancy
CreatedAt time.Time `json:"created_at"` // When the session was created
ExpiresAt time.Time `json:"expires_at"` // When the session expires
}
// NewSession creates a new session
func NewSession(userID uint64, userUUID uuid.UUID, userEmail, userName, userRole string, tenantID uuid.UUID, duration time.Duration) *Session {
now := time.Now()
return &Session{
ID: gocql.TimeUUID().String(),
UserID: userID,
UserUUID: userUUID,
UserEmail: userEmail,
UserName: userName,
UserRole: userRole,
TenantID: tenantID,
CreatedAt: now,
ExpiresAt: now.Add(duration),
}
}
// IsExpired checks if the session has expired
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}

View file

@ -0,0 +1,35 @@
package site
import "errors"
var (
// ErrNotFound is returned when a site is not found
ErrNotFound = errors.New("site not found")
// ErrSiteNotFound is an alias for ErrNotFound
ErrSiteNotFound = ErrNotFound
// ErrDomainAlreadyExists is returned when trying to create a site with a domain that already exists
ErrDomainAlreadyExists = errors.New("domain already exists")
// ErrInvalidAPIKey is returned when API key authentication fails
ErrInvalidAPIKey = errors.New("invalid API key")
// ErrSiteNotActive is returned when trying to perform operations on an inactive site
ErrSiteNotActive = errors.New("site is not active")
// ErrSiteNotVerified is returned when trying to perform operations on an unverified site
ErrSiteNotVerified = errors.New("site is not verified")
// ErrQuotaExceeded is returned when a quota limit is reached
ErrQuotaExceeded = errors.New("quota exceeded")
// ErrStorageQuotaExceeded is returned when storage quota is exceeded
ErrStorageQuotaExceeded = errors.New("storage quota exceeded")
// ErrSearchQuotaExceeded is returned when search quota is exceeded
ErrSearchQuotaExceeded = errors.New("search quota exceeded")
// ErrIndexingQuotaExceeded is returned when indexing quota is exceeded
ErrIndexingQuotaExceeded = errors.New("indexing quota exceeded")
)

View file

@ -0,0 +1,45 @@
package site
import (
"context"
"github.com/gocql/gocql"
)
// Repository defines the interface for site data access
type Repository interface {
// Create inserts a new site into all Cassandra tables
Create(ctx context.Context, site *Site) error
// GetByID retrieves a site by tenant_id and site_id
GetByID(ctx context.Context, tenantID, siteID gocql.UUID) (*Site, error)
// GetByDomain retrieves a site by domain name
GetByDomain(ctx context.Context, domain string) (*Site, error)
// GetByAPIKeyHash retrieves a site by API key hash (for authentication)
GetByAPIKeyHash(ctx context.Context, apiKeyHash string) (*Site, error)
// ListByTenant retrieves all sites for a tenant (paginated)
ListByTenant(ctx context.Context, tenantID gocql.UUID, pageSize int, pageState []byte) ([]*Site, []byte, error)
// Update updates a site in all Cassandra tables
Update(ctx context.Context, site *Site) error
// UpdateAPIKey updates the API key for a site (handles sites_by_apikey table correctly)
// Must provide both old and new API key hashes to properly delete old entry and insert new one
UpdateAPIKey(ctx context.Context, site *Site, oldAPIKeyHash string) error
// Delete removes a site from all Cassandra tables
Delete(ctx context.Context, tenantID, siteID gocql.UUID) error
// DomainExists checks if a domain is already registered
DomainExists(ctx context.Context, domain string) (bool, error)
// UpdateUsage updates only usage tracking fields (optimized for frequent updates)
UpdateUsage(ctx context.Context, site *Site) error
// GetAllSitesForUsageReset retrieves all sites for monthly usage counter reset
// This uses ALLOW FILTERING and should only be used for administrative tasks
GetAllSitesForUsageReset(ctx context.Context, pageSize int, pageState []byte) ([]*Site, []byte, error)
}

View file

@ -0,0 +1,187 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/site/site.go
package site
import (
"time"
"github.com/gocql/gocql"
)
// Site represents a WordPress site registered in the system
type Site struct {
// Core Identity
ID gocql.UUID `json:"id"`
TenantID gocql.UUID `json:"tenant_id"`
// Site Information
SiteURL string `json:"site_url"` // Full URL: https://example.com
Domain string `json:"domain"` // Extracted: example.com
// Authentication
APIKeyHash string `json:"-"` // SHA-256 hash, never exposed in JSON
APIKeyPrefix string `json:"api_key_prefix"` // "live_sk_a1b2" for display
APIKeyLastFour string `json:"api_key_last_four"` // Last 4 chars for display
// Status & Verification
Status string `json:"status"` // active, inactive, pending, suspended, archived
IsVerified bool `json:"is_verified"`
VerificationToken string `json:"-"` // Never exposed
// Search & Indexing
SearchIndexName string `json:"search_index_name"`
TotalPagesIndexed int64 `json:"total_pages_indexed"` // All-time total for stats
LastIndexedAt time.Time `json:"last_indexed_at,omitempty"`
// Plugin Info
PluginVersion string `json:"plugin_version,omitempty"`
// Usage Tracking (for billing) - no quotas/limits
StorageUsedBytes int64 `json:"storage_used_bytes"` // Current storage usage
SearchRequestsCount int64 `json:"search_requests_count"` // Monthly search count
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"` // Monthly indexing count
LastResetAt time.Time `json:"last_reset_at"` // Last monthly reset
// Metadata (optional fields)
Language string `json:"language,omitempty"` // ISO 639-1
Timezone string `json:"timezone,omitempty"` // IANA timezone
Notes string `json:"notes,omitempty"`
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
CreatedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
CreatedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
ModifiedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
ModifiedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
}
// Status constants
const (
StatusPending = "pending" // Site created, awaiting verification
StatusActive = "active" // Site verified and operational
StatusInactive = "inactive" // User temporarily disabled
StatusSuspended = "suspended" // Suspended due to violation or non-payment
StatusArchived = "archived" // Soft deleted
)
// NewSite creates a new Site entity with defaults
func NewSite(tenantID gocql.UUID, domain, siteURL string, apiKeyHash, apiKeyPrefix, apiKeyLastFour string, encryptedIP string) *Site {
now := time.Now()
siteID := gocql.TimeUUID()
return &Site{
ID: siteID,
TenantID: tenantID,
Domain: domain,
SiteURL: siteURL,
APIKeyHash: apiKeyHash,
APIKeyPrefix: apiKeyPrefix,
APIKeyLastFour: apiKeyLastFour,
Status: StatusPending,
IsVerified: false,
VerificationToken: "", // Set by caller
SearchIndexName: "site_" + siteID.String(),
TotalPagesIndexed: 0,
PluginVersion: "",
// Usage tracking (no quotas/limits)
StorageUsedBytes: 0,
SearchRequestsCount: 0,
MonthlyPagesIndexed: 0,
LastResetAt: now,
Language: "",
Timezone: "",
Notes: "",
CreatedAt: now,
UpdatedAt: now,
// CWE-359: Encrypted IP address tracking for GDPR compliance
CreatedFromIPAddress: encryptedIP,
CreatedFromIPTimestamp: now,
ModifiedFromIPAddress: encryptedIP,
ModifiedFromIPTimestamp: now,
}
}
// IsActive checks if the site is active and verified
func (s *Site) IsActive() bool {
return s.Status == StatusActive && s.IsVerified
}
// IsTestMode checks if the site is using a test API key
func (s *Site) IsTestMode() bool {
// Check if API key prefix starts with "test_sk_"
return len(s.APIKeyPrefix) >= 7 && s.APIKeyPrefix[:7] == "test_sk"
}
// RequiresVerification checks if the site requires verification
// Test mode sites skip verification for development
func (s *Site) RequiresVerification() bool {
return !s.IsTestMode()
}
// CanAccessAPI checks if the site can access the API
// More lenient than IsActive - allows pending sites for initial setup
func (s *Site) CanAccessAPI() bool {
// Allow active sites (fully verified)
if s.Status == StatusActive {
return true
}
// Allow pending sites (waiting for verification) for initial setup
if s.Status == StatusPending {
return true
}
// Block inactive, suspended, or archived sites
return false
}
// IncrementSearchCount increments the search request counter
func (s *Site) IncrementSearchCount() {
s.SearchRequestsCount++
s.UpdatedAt = time.Now()
}
// IncrementPageCount increments the indexed page counter (lifetime total)
func (s *Site) IncrementPageCount() {
s.TotalPagesIndexed++
s.UpdatedAt = time.Now()
}
// IncrementMonthlyPageCount increments both lifetime and monthly page counters
func (s *Site) IncrementMonthlyPageCount(count int64) {
s.TotalPagesIndexed += count
s.MonthlyPagesIndexed += count
s.LastIndexedAt = time.Now()
s.UpdatedAt = time.Now()
}
// UpdateStorageUsed updates the storage usage
func (s *Site) UpdateStorageUsed(bytes int64) {
s.StorageUsedBytes = bytes
s.UpdatedAt = time.Now()
}
// Verify marks the site as verified
func (s *Site) Verify() {
s.IsVerified = true
s.Status = StatusActive
s.VerificationToken = "" // Clear token after verification
s.UpdatedAt = time.Now()
}
// ResetMonthlyUsage resets monthly usage counters for billing cycles
func (s *Site) ResetMonthlyUsage() {
now := time.Now()
// Reset usage counters (no quotas)
s.SearchRequestsCount = 0
s.MonthlyPagesIndexed = 0
s.LastResetAt = now
s.UpdatedAt = now
}

View file

@ -0,0 +1,75 @@
package tenant
import (
"errors"
"regexp"
"time"
)
var (
ErrNameRequired = errors.New("tenant name is required")
ErrNameTooShort = errors.New("tenant name must be at least 2 characters")
ErrNameTooLong = errors.New("tenant name must not exceed 100 characters")
ErrSlugRequired = errors.New("tenant slug is required")
ErrSlugInvalid = errors.New("tenant slug must contain only lowercase letters, numbers, and hyphens")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantExists = errors.New("tenant already exists")
ErrTenantInactive = errors.New("tenant is inactive")
)
// Status represents the tenant's current status
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
StatusSuspended Status = "suspended"
)
// Tenant represents a tenant in the system
// Each tenant is a separate customer/organization
type Tenant struct {
ID string
Name string // Display name (e.g., "Acme Corporation")
Slug string // URL-friendly identifier (e.g., "acme-corp")
Status Status
CreatedAt time.Time
UpdatedAt time.Time
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
CreatedFromIPAddress string // Encrypted IP address
CreatedFromIPTimestamp time.Time // For 90-day expiration tracking
ModifiedFromIPAddress string // Encrypted IP address
ModifiedFromIPTimestamp time.Time // For 90-day expiration tracking
}
var slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// Validate validates the tenant entity
func (t *Tenant) Validate() error {
// Name validation
if t.Name == "" {
return ErrNameRequired
}
if len(t.Name) < 2 {
return ErrNameTooShort
}
if len(t.Name) > 100 {
return ErrNameTooLong
}
// Slug validation
if t.Slug == "" {
return ErrSlugRequired
}
if !slugRegex.MatchString(t.Slug) {
return ErrSlugInvalid
}
return nil
}
// IsActive returns true if the tenant is active
func (t *Tenant) IsActive() bool {
return t.Status == StatusActive
}

View file

@ -0,0 +1,16 @@
package tenant
import "context"
// Repository defines data access for tenants
// Note: Tenant operations do NOT require tenantID parameter since
// tenants are the top-level entity in our multi-tenant architecture
type Repository interface {
Create(ctx context.Context, tenant *Tenant) error
GetByID(ctx context.Context, id string) (*Tenant, error)
GetBySlug(ctx context.Context, slug string) (*Tenant, error)
Update(ctx context.Context, tenant *Tenant) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, limit int) ([]*Tenant, error)
ListByStatus(ctx context.Context, status Status, limit int) ([]*Tenant, error)
}

View file

@ -0,0 +1,169 @@
package user
import (
"errors"
"regexp"
"time"
)
// User represents a user entity in the domain
// Every user strictly belongs to a tenant
type User struct {
ID string
Email string
FirstName string
LastName string
Name string
LexicalName string
Timezone string
// Role management
Role int
// State management
Status int
// Embedded structs for better organization
ProfileData *UserProfileData
// Encapsulating security related data
SecurityData *UserSecurityData
// Metadata about the user
Metadata *UserMetadata
// Limited metadata fields used for querying
TenantID string // Every user belongs to a tenant
CreatedAt time.Time
UpdatedAt time.Time
}
// UserProfileData contains user profile information
type UserProfileData struct {
Phone string
Country string
Region string
City string
PostalCode string
AddressLine1 string
AddressLine2 string
HasShippingAddress bool
ShippingName string
ShippingPhone string
ShippingCountry string
ShippingRegion string
ShippingCity string
ShippingPostalCode string
ShippingAddressLine1 string
ShippingAddressLine2 string
Timezone string
AgreeTermsOfService bool
AgreePromotions bool
AgreeToTrackingAcrossThirdPartyAppsAndServices bool
}
// UserMetadata contains audit and tracking information
type UserMetadata struct {
// CWE-359: Encrypted IP addresses for GDPR compliance
CreatedFromIPAddress string // Encrypted with go-ipcrypt
CreatedFromIPTimestamp time.Time // For 90-day expiration tracking
CreatedByUserID string
CreatedAt time.Time
CreatedByName string
ModifiedFromIPAddress string // Encrypted with go-ipcrypt
ModifiedFromIPTimestamp time.Time // For 90-day expiration tracking
ModifiedByUserID string
ModifiedAt time.Time
ModifiedByName string
LastLoginAt time.Time
}
// FullName returns the user's full name computed from FirstName and LastName
func (u *User) FullName() string {
if u.FirstName == "" && u.LastName == "" {
return u.Name // Fallback to Name field if first/last are empty
}
return u.FirstName + " " + u.LastName
}
// UserSecurityData contains security-related information
type UserSecurityData struct {
PasswordHashAlgorithm string
PasswordHash string
WasEmailVerified bool
Code string
CodeType string // 'email_verification' or 'password_reset'
CodeExpiry time.Time
// OTPEnabled controls whether we force 2FA or not during login
OTPEnabled bool
// OTPVerified indicates user has successfully validated their OTP token after enabling 2FA
OTPVerified bool
// OTPValidated automatically gets set as `false` on successful login and then sets `true` once successfully validated by 2FA
OTPValidated bool
// OTPSecret the unique one-time password secret to be shared between our backend and 2FA authenticator apps
OTPSecret string
// OTPAuthURL is the URL used to share
OTPAuthURL string
// OTPBackupCodeHash is the one-time use backup code which resets the 2FA settings
OTPBackupCodeHash string
// OTPBackupCodeHashAlgorithm tracks the hashing algorithm used
OTPBackupCodeHashAlgorithm string
}
// Domain errors
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidEmail = errors.New("invalid email format")
ErrEmailRequired = errors.New("email is required")
ErrFirstNameRequired = errors.New("first name is required")
ErrLastNameRequired = errors.New("last name is required")
ErrNameRequired = errors.New("name is required")
ErrTenantIDRequired = errors.New("tenant ID is required")
ErrPasswordRequired = errors.New("password is required")
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrPasswordTooWeak = errors.New("password must contain uppercase, lowercase, number, and special character")
ErrRoleRequired = errors.New("role is required")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrTermsOfServiceRequired = errors.New("must agree to terms of service")
)
// Email validation regex (basic)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Validate validates the user entity
func (u *User) Validate() error {
if u.TenantID == "" {
return ErrTenantIDRequired
}
if u.Email == "" {
return ErrEmailRequired
}
if !emailRegex.MatchString(u.Email) {
return ErrInvalidEmail
}
if u.Name == "" {
return ErrNameRequired
}
// Validate ProfileData if present
if u.ProfileData != nil {
// Terms of Service is REQUIRED
if !u.ProfileData.AgreeTermsOfService {
return ErrTermsOfServiceRequired
}
}
return nil
}

View file

@ -0,0 +1,29 @@
package user
import "context"
// Repository defines the interface for user data access
// All methods require tenantID for multi-tenant isolation
type Repository interface {
// Create creates a new user
Create(ctx context.Context, tenantID string, user *User) error
// GetByID retrieves a user by ID
GetByID(ctx context.Context, tenantID string, id string) (*User, error)
// GetByEmail retrieves a user by email within a specific tenant
GetByEmail(ctx context.Context, tenantID string, email string) (*User, error)
// GetByEmailGlobal retrieves a user by email across all tenants (for login)
// This should only be used for authentication where tenant is not yet known
GetByEmailGlobal(ctx context.Context, email string) (*User, error)
// Update updates an existing user
Update(ctx context.Context, tenantID string, user *User) error
// Delete deletes a user by ID
Delete(ctx context.Context, tenantID string, id string) error
// ListByDate lists users created within a date range
ListByDate(ctx context.Context, tenantID string, startDate, endDate string, limit int) ([]*User, error)
}