Finished step '1.2.1 Create Shared Package Directory Structure' of the task 'Task 2: Set Up Shared Packages Structure' of the detailed implementation plan @docs/4_work_plan/1.1-development-environment-setup.md

master
oabrivard 2 months ago
parent 203f76166e
commit b97644a540

@ -1,17 +1,59 @@
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY= github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY= github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -0,0 +1,49 @@
// Package errors provides domain-specific error types for the KnowFoolery application.
package errors
// ErrorCode represents a unique error code for domain errors.
type ErrorCode string
// Error codes for the KnowFoolery application.
const (
// General errors
CodeNotFound ErrorCode = "NOT_FOUND"
CodeInvalidInput ErrorCode = "INVALID_INPUT"
CodeUnauthorized ErrorCode = "UNAUTHORIZED"
CodeForbidden ErrorCode = "FORBIDDEN"
CodeConflict ErrorCode = "CONFLICT"
CodeInternal ErrorCode = "INTERNAL"
// Game session errors
CodeSessionExpired ErrorCode = "SESSION_EXPIRED"
CodeGameInProgress ErrorCode = "GAME_IN_PROGRESS"
CodeMaxAttemptsReached ErrorCode = "MAX_ATTEMPTS_REACHED"
CodeSessionNotActive ErrorCode = "SESSION_NOT_ACTIVE"
// Question errors
CodeQuestionNotFound ErrorCode = "QUESTION_NOT_FOUND"
CodeNoQuestionsAvailable ErrorCode = "NO_QUESTIONS_AVAILABLE"
// User errors
CodeUserNotFound ErrorCode = "USER_NOT_FOUND"
CodeUserAlreadyExists ErrorCode = "USER_ALREADY_EXISTS"
CodeEmailNotVerified ErrorCode = "EMAIL_NOT_VERIFIED"
// Validation errors
CodeValidationFailed ErrorCode = "VALIDATION_FAILED"
CodeInvalidPlayerName ErrorCode = "INVALID_PLAYER_NAME"
CodeInvalidAnswer ErrorCode = "INVALID_ANSWER"
// Authentication errors
CodeInvalidToken ErrorCode = "INVALID_TOKEN"
CodeTokenExpired ErrorCode = "TOKEN_EXPIRED"
CodeMFARequired ErrorCode = "MFA_REQUIRED"
// Rate limiting errors
CodeRateLimitExceeded ErrorCode = "RATE_LIMIT_EXCEEDED"
)
// String returns the string representation of the error code.
func (c ErrorCode) String() string {
return string(c)
}

@ -0,0 +1,83 @@
// Package errors provides domain-specific error types for the KnowFoolery application.
package errors
import (
"errors"
"fmt"
)
// DomainError represents a domain-level error with an error code.
type DomainError struct {
Code ErrorCode
Message string
Err error
}
// Error implements the error interface.
func (e *DomainError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// Unwrap returns the underlying error.
func (e *DomainError) Unwrap() error {
return e.Err
}
// Is checks if the target error matches this DomainError's code.
func (e *DomainError) Is(target error) bool {
var domainErr *DomainError
if errors.As(target, &domainErr) {
return e.Code == domainErr.Code
}
return false
}
// New creates a new DomainError with the given code and message.
func New(code ErrorCode, message string) *DomainError {
return &DomainError{
Code: code,
Message: message,
}
}
// Wrap wraps an existing error with a DomainError.
func Wrap(code ErrorCode, message string, err error) *DomainError {
return &DomainError{
Code: code,
Message: message,
Err: err,
}
}
// Common domain errors
var (
// ErrNotFound indicates a requested resource was not found.
ErrNotFound = New(CodeNotFound, "resource not found")
// ErrInvalidInput indicates invalid input was provided.
ErrInvalidInput = New(CodeInvalidInput, "invalid input")
// ErrUnauthorized indicates the user is not authenticated.
ErrUnauthorized = New(CodeUnauthorized, "unauthorized")
// ErrForbidden indicates the user lacks permission.
ErrForbidden = New(CodeForbidden, "forbidden")
// ErrConflict indicates a resource conflict.
ErrConflict = New(CodeConflict, "resource conflict")
// ErrInternal indicates an internal server error.
ErrInternal = New(CodeInternal, "internal error")
// ErrSessionExpired indicates a game session has expired.
ErrSessionExpired = New(CodeSessionExpired, "session expired")
// ErrGameInProgress indicates a game is already in progress.
ErrGameInProgress = New(CodeGameInProgress, "game already in progress")
// ErrMaxAttemptsReached indicates the maximum attempts have been reached.
ErrMaxAttemptsReached = New(CodeMaxAttemptsReached, "maximum attempts reached")
)

@ -0,0 +1,54 @@
// Package events provides domain event interfaces and types for the KnowFoolery application.
package events
// EventType represents the type of a domain event.
type EventType string
// Game session events
const (
// GameSessionStarted is emitted when a new game session starts.
GameSessionStarted EventType = "game_session.started"
// GameSessionEnded is emitted when a game session ends.
GameSessionEnded EventType = "game_session.ended"
// GameSessionTimedOut is emitted when a game session times out.
GameSessionTimedOut EventType = "game_session.timed_out"
// AnswerSubmitted is emitted when a player submits an answer.
AnswerSubmitted EventType = "game_session.answer_submitted"
// HintRequested is emitted when a player requests a hint.
HintRequested EventType = "game_session.hint_requested"
// QuestionAnswered is emitted when a question is fully answered (correct or max attempts).
QuestionAnswered EventType = "game_session.question_answered"
)
// User events
const (
// UserRegistered is emitted when a new user registers.
UserRegistered EventType = "user.registered"
// UserEmailVerified is emitted when a user verifies their email.
UserEmailVerified EventType = "user.email_verified"
// UserDeleted is emitted when a user account is deleted.
UserDeleted EventType = "user.deleted"
)
// Question events
const (
// QuestionCreated is emitted when a new question is created.
QuestionCreated EventType = "question.created"
// QuestionUpdated is emitted when a question is updated.
QuestionUpdated EventType = "question.updated"
// QuestionDeleted is emitted when a question is deleted.
QuestionDeleted EventType = "question.deleted"
)
// Leaderboard events
const (
// ScoreUpdated is emitted when a score is updated on the leaderboard.
ScoreUpdated EventType = "leaderboard.score_updated"
// LeaderboardRefreshed is emitted when the leaderboard is refreshed.
LeaderboardRefreshed EventType = "leaderboard.refreshed"
)
// String returns the string representation of the event type.
func (t EventType) String() string {
return string(t)
}

@ -0,0 +1,73 @@
// Package events provides domain event interfaces and types for the KnowFoolery application.
package events
import (
"context"
"time"
)
// Event represents a domain event.
type Event interface {
// EventType returns the type of the event.
EventType() EventType
// OccurredAt returns when the event occurred.
OccurredAt() time.Time
// AggregateID returns the ID of the aggregate that produced the event.
AggregateID() string
// AggregateType returns the type of the aggregate.
AggregateType() string
}
// BaseEvent provides a base implementation of the Event interface.
type BaseEvent struct {
Type EventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
AggrID string `json:"aggregate_id"`
AggrType string `json:"aggregate_type"`
}
// EventType returns the type of the event.
func (e *BaseEvent) EventType() EventType {
return e.Type
}
// OccurredAt returns when the event occurred.
func (e *BaseEvent) OccurredAt() time.Time {
return e.Timestamp
}
// AggregateID returns the ID of the aggregate that produced the event.
func (e *BaseEvent) AggregateID() string {
return e.AggrID
}
// AggregateType returns the type of the aggregate.
func (e *BaseEvent) AggregateType() string {
return e.AggrType
}
// NewBaseEvent creates a new BaseEvent with the given parameters.
func NewBaseEvent(eventType EventType, aggregateID, aggregateType string) BaseEvent {
return BaseEvent{
Type: eventType,
Timestamp: time.Now(),
AggrID: aggregateID,
AggrType: aggregateType,
}
}
// EventHandler handles domain events.
type EventHandler interface {
// Handle processes the given event.
Handle(ctx context.Context, event Event) error
// Handles returns the event types this handler can process.
Handles() []EventType
}
// EventBus publishes and subscribes to domain events.
type EventBus interface {
// Publish publishes an event to all subscribers.
Publish(ctx context.Context, event Event) error
// Subscribe registers a handler for specific event types.
Subscribe(handler EventHandler) error
}

@ -0,0 +1,79 @@
// Package types provides common domain types for the KnowFoolery application.
package types
// SessionStatus represents the status of a game session.
type SessionStatus string
const (
// SessionStatusCreated indicates a session has been created but not started.
SessionStatusCreated SessionStatus = "created"
// SessionStatusActive indicates a session is currently active.
SessionStatusActive SessionStatus = "active"
// SessionStatusCompleted indicates a session has been completed normally.
SessionStatusCompleted SessionStatus = "completed"
// SessionStatusTimedOut indicates a session has timed out.
SessionStatusTimedOut SessionStatus = "timed_out"
// SessionStatusAbandoned indicates a session has been abandoned.
SessionStatusAbandoned SessionStatus = "abandoned"
)
// String returns the string representation of the session status.
func (s SessionStatus) String() string {
return string(s)
}
// IsTerminal checks if the session status is terminal (cannot transition).
func (s SessionStatus) IsTerminal() bool {
return s == SessionStatusCompleted || s == SessionStatusTimedOut || s == SessionStatusAbandoned
}
// Difficulty represents the difficulty level of a question.
type Difficulty string
const (
// DifficultyEasy represents an easy question.
DifficultyEasy Difficulty = "easy"
// DifficultyMedium represents a medium difficulty question.
DifficultyMedium Difficulty = "medium"
// DifficultyHard represents a hard question.
DifficultyHard Difficulty = "hard"
)
// String returns the string representation of the difficulty.
func (d Difficulty) String() string {
return string(d)
}
// UserRole represents a user's role in the system.
type UserRole string
const (
// RolePlayer represents a regular player.
RolePlayer UserRole = "player"
// RoleAdmin represents an administrator.
RoleAdmin UserRole = "admin"
// RoleModerator represents a moderator.
RoleModerator UserRole = "moderator"
)
// String returns the string representation of the user role.
func (r UserRole) String() string {
return string(r)
}
// CompletionType represents how a game session was completed.
type CompletionType string
const (
// CompletionNormal indicates normal completion.
CompletionNormal CompletionType = "normal"
// CompletionTimeout indicates the session timed out.
CompletionTimeout CompletionType = "timeout"
// CompletionAbandoned indicates the session was abandoned.
CompletionAbandoned CompletionType = "abandoned"
)
// String returns the string representation of the completion type.
func (c CompletionType) String() string {
return string(c)
}

@ -0,0 +1,50 @@
// Package types provides common domain types for the KnowFoolery application.
package types
import (
"github.com/google/uuid"
)
// ID represents a unique identifier.
type ID string
// NewID generates a new unique ID.
func NewID() ID {
return ID(uuid.New().String())
}
// IDFromString creates an ID from a string.
func IDFromString(s string) ID {
return ID(s)
}
// String returns the string representation of the ID.
func (id ID) String() string {
return string(id)
}
// IsEmpty checks if the ID is empty.
func (id ID) IsEmpty() bool {
return id == ""
}
// IsValid checks if the ID is a valid UUID.
func (id ID) IsValid() bool {
if id.IsEmpty() {
return false
}
_, err := uuid.Parse(string(id))
return err == nil
}
// SessionID represents a game session identifier.
type SessionID = ID
// UserID represents a user identifier.
type UserID = ID
// QuestionID represents a question identifier.
type QuestionID = ID
// ThemeID represents a theme identifier.
type ThemeID = ID

@ -0,0 +1,90 @@
// Package types provides common domain types for the KnowFoolery application.
package types
const (
// DefaultPageSize is the default number of items per page.
DefaultPageSize = 20
// MaxPageSize is the maximum number of items per page.
MaxPageSize = 100
)
// Pagination represents pagination parameters.
type Pagination struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// NewPagination creates a new Pagination with default values.
func NewPagination() Pagination {
return Pagination{
Page: 1,
PageSize: DefaultPageSize,
}
}
// Offset returns the offset for database queries.
func (p Pagination) Offset() int {
if p.Page < 1 {
p.Page = 1
}
return (p.Page - 1) * p.Limit()
}
// Limit returns the limit for database queries.
func (p Pagination) Limit() int {
if p.PageSize < 1 {
return DefaultPageSize
}
if p.PageSize > MaxPageSize {
return MaxPageSize
}
return p.PageSize
}
// Normalize ensures pagination values are within valid ranges.
func (p *Pagination) Normalize() {
if p.Page < 1 {
p.Page = 1
}
if p.PageSize < 1 {
p.PageSize = DefaultPageSize
}
if p.PageSize > MaxPageSize {
p.PageSize = MaxPageSize
}
}
// PaginatedResult represents a paginated result set.
type PaginatedResult[T any] struct {
Items []T `json:"items"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// NewPaginatedResult creates a new PaginatedResult.
func NewPaginatedResult[T any](items []T, totalCount int64, pagination Pagination) PaginatedResult[T] {
totalPages := int(totalCount) / pagination.Limit()
if int(totalCount)%pagination.Limit() > 0 {
totalPages++
}
return PaginatedResult[T]{
Items: items,
TotalCount: totalCount,
Page: pagination.Page,
PageSize: pagination.Limit(),
TotalPages: totalPages,
}
}
// HasNextPage checks if there is a next page.
func (r PaginatedResult[T]) HasNextPage() bool {
return r.Page < r.TotalPages
}
// HasPreviousPage checks if there is a previous page.
func (r PaginatedResult[T]) HasPreviousPage() bool {
return r.Page > 1
}

@ -0,0 +1,77 @@
// Package valueobjects provides domain value objects for the KnowFoolery application.
package valueobjects
import (
"regexp"
"strings"
"knowfoolery/backend/shared/domain/errors"
)
const (
// MinPlayerNameLength is the minimum length of a player name.
MinPlayerNameLength = 2
// MaxPlayerNameLength is the maximum length of a player name.
MaxPlayerNameLength = 50
)
// playerNamePattern matches alphanumeric characters, spaces, hyphens, underscores, and dots.
var playerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.]+$`)
// PlayerName represents a validated player name.
type PlayerName struct {
value string
}
// NewPlayerName creates a new PlayerName with validation.
func NewPlayerName(name string) (PlayerName, error) {
// Trim whitespace
name = strings.TrimSpace(name)
// Validate length
if len(name) < MinPlayerNameLength {
return PlayerName{}, errors.Wrap(
errors.CodeInvalidPlayerName,
"player name too short",
nil,
)
}
if len(name) > MaxPlayerNameLength {
return PlayerName{}, errors.Wrap(
errors.CodeInvalidPlayerName,
"player name too long",
nil,
)
}
// Validate characters
if !playerNamePattern.MatchString(name) {
return PlayerName{}, errors.Wrap(
errors.CodeInvalidPlayerName,
"player name contains invalid characters",
nil,
)
}
// Normalize multiple spaces to single space
spaceRegex := regexp.MustCompile(`\s+`)
name = spaceRegex.ReplaceAllString(name, " ")
return PlayerName{value: name}, nil
}
// String returns the string representation of the player name.
func (p PlayerName) String() string {
return p.value
}
// Equals checks if two player names are equal.
func (p PlayerName) Equals(other PlayerName) bool {
return strings.EqualFold(p.value, other.value)
}
// IsEmpty checks if the player name is empty.
func (p PlayerName) IsEmpty() bool {
return p.value == ""
}

@ -0,0 +1,91 @@
// Package valueobjects provides domain value objects for the KnowFoolery application.
package valueobjects
// Scoring constants
const (
// MaxScorePerQuestion is the maximum score for a correct answer without hint.
MaxScorePerQuestion = 2
// ScoreWithHint is the score for a correct answer with hint.
ScoreWithHint = 1
// ScoreIncorrect is the score for an incorrect answer.
ScoreIncorrect = 0
// MaxAttempts is the maximum number of attempts per question.
MaxAttempts = 3
)
// Score represents a game score value.
type Score struct {
value int
}
// NewScore creates a new Score with validation.
func NewScore(value int) Score {
if value < 0 {
value = 0
}
return Score{value: value}
}
// Zero returns a zero score.
func Zero() Score {
return Score{value: 0}
}
// Value returns the score value.
func (s Score) Value() int {
return s.value
}
// Add adds points to the score and returns a new Score.
func (s Score) Add(points int) Score {
newValue := s.value + points
if newValue < 0 {
newValue = 0
}
return Score{value: newValue}
}
// CalculateQuestionScore calculates the score for a question based on correctness and hint usage.
func CalculateQuestionScore(isCorrect bool, usedHint bool) int {
if !isCorrect {
return ScoreIncorrect
}
if usedHint {
return ScoreWithHint
}
return MaxScorePerQuestion
}
// Attempt represents an attempt at answering a question.
type Attempt struct {
Number int
Answer string
Correct bool
UsedHint bool
Score int
}
// NewAttempt creates a new Attempt.
func NewAttempt(number int, answer string, correct bool, usedHint bool) Attempt {
return Attempt{
Number: number,
Answer: answer,
Correct: correct,
UsedHint: usedHint,
Score: CalculateQuestionScore(correct, usedHint),
}
}
// CanRetry checks if another attempt is allowed.
func CanRetry(attemptNumber int) bool {
return attemptNumber < MaxAttempts
}
// RemainingAttempts returns the number of remaining attempts.
func RemainingAttempts(attemptNumber int) int {
remaining := MaxAttempts - attemptNumber
if remaining < 0 {
return 0
}
return remaining
}

@ -1,3 +1,37 @@
module knowfoolery/backend/shared module knowfoolery/backend/shared
go 1.25.5 go 1.25.5
require (
github.com/go-playground/validator/v10 v10.25.0
github.com/gofiber/fiber/v3 v3.0.0-beta.3
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.20.5
github.com/rs/zerolog v1.33.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

@ -0,0 +1,77 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -0,0 +1,157 @@
// Package rbac provides role-based access control for the KnowFoolery application.
package rbac
// Role represents a user role.
type Role string
// Predefined roles
const (
RolePlayer Role = "player"
RoleAdmin Role = "admin"
RoleModerator Role = "moderator"
)
// Permission represents a permission in the system.
type Permission string
// Game permissions
const (
PermissionPlayGame Permission = "game:play"
PermissionViewGame Permission = "game:view"
)
// Question permissions
const (
PermissionViewQuestion Permission = "question:view"
PermissionCreateQuestion Permission = "question:create"
PermissionUpdateQuestion Permission = "question:update"
PermissionDeleteQuestion Permission = "question:delete"
)
// User permissions
const (
PermissionViewOwnProfile Permission = "user:view:own"
PermissionUpdateOwnProfile Permission = "user:update:own"
PermissionDeleteOwnAccount Permission = "user:delete:own"
PermissionViewUsers Permission = "user:view:all"
PermissionManageUsers Permission = "user:manage"
)
// Leaderboard permissions
const (
PermissionViewLeaderboard Permission = "leaderboard:view"
)
// Admin permissions
const (
PermissionViewAuditLog Permission = "audit:view"
PermissionViewDashboard Permission = "dashboard:view"
PermissionManageSystem Permission = "system:manage"
)
// rolePermissions maps roles to their permissions.
var rolePermissions = map[Role][]Permission{
RolePlayer: {
PermissionPlayGame,
PermissionViewGame,
PermissionViewQuestion,
PermissionViewLeaderboard,
PermissionViewOwnProfile,
PermissionUpdateOwnProfile,
PermissionDeleteOwnAccount,
},
RoleModerator: {
PermissionPlayGame,
PermissionViewGame,
PermissionViewQuestion,
PermissionCreateQuestion,
PermissionUpdateQuestion,
PermissionViewLeaderboard,
PermissionViewOwnProfile,
PermissionUpdateOwnProfile,
PermissionDeleteOwnAccount,
PermissionViewUsers,
},
RoleAdmin: {
PermissionPlayGame,
PermissionViewGame,
PermissionViewQuestion,
PermissionCreateQuestion,
PermissionUpdateQuestion,
PermissionDeleteQuestion,
PermissionViewLeaderboard,
PermissionViewOwnProfile,
PermissionUpdateOwnProfile,
PermissionDeleteOwnAccount,
PermissionViewUsers,
PermissionManageUsers,
PermissionViewAuditLog,
PermissionViewDashboard,
PermissionManageSystem,
},
}
// HasPermission checks if a role has a specific permission.
func HasPermission(role Role, permission Permission) bool {
permissions, ok := rolePermissions[role]
if !ok {
return false
}
for _, p := range permissions {
if p == permission {
return true
}
}
return false
}
// HasAnyPermission checks if a role has any of the specified permissions.
func HasAnyPermission(role Role, permissions ...Permission) bool {
for _, permission := range permissions {
if HasPermission(role, permission) {
return true
}
}
return false
}
// HasAllPermissions checks if a role has all of the specified permissions.
func HasAllPermissions(role Role, permissions ...Permission) bool {
for _, permission := range permissions {
if !HasPermission(role, permission) {
return false
}
}
return true
}
// UserHasPermission checks if a user with the given roles has a specific permission.
func UserHasPermission(roles []string, permission Permission) bool {
for _, roleStr := range roles {
if HasPermission(Role(roleStr), permission) {
return true
}
}
return false
}
// GetPermissions returns all permissions for a role.
func GetPermissions(role Role) []Permission {
permissions, ok := rolePermissions[role]
if !ok {
return nil
}
// Return a copy to prevent modification
result := make([]Permission, len(permissions))
copy(result, permissions)
return result
}
// IsValidRole checks if a role string is a valid role.
func IsValidRole(roleStr string) bool {
role := Role(roleStr)
_, ok := rolePermissions[role]
return ok
}

@ -0,0 +1,143 @@
// Package zitadel provides Zitadel authentication client for the KnowFoolery application.
package zitadel
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// Config holds the configuration for the Zitadel client.
type Config struct {
BaseURL string
ProjectID string
AdminToken string
Timeout time.Duration
}
// DefaultConfig returns a default configuration.
func DefaultConfig() Config {
return Config{
Timeout: 10 * time.Second,
}
}
// Client provides access to Zitadel authentication services.
type Client struct {
config Config
httpClient *http.Client
jwksCache *JWKSCache
}
// JWKSCache caches the JSON Web Key Set for token validation.
type JWKSCache struct {
mu sync.RWMutex
keys map[string]interface{}
expiry time.Time
duration time.Duration
}
// NewJWKSCache creates a new JWKS cache.
func NewJWKSCache(cacheDuration time.Duration) *JWKSCache {
return &JWKSCache{
keys: make(map[string]interface{}),
duration: cacheDuration,
}
}
// NewClient creates a new Zitadel client.
func NewClient(config Config) *Client {
return &Client{
config: config,
httpClient: &http.Client{
Timeout: config.Timeout,
},
jwksCache: NewJWKSCache(5 * time.Minute),
}
}
// AuthClaims represents the claims extracted from a validated JWT.
type AuthClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"urn:zitadel:iam:org:project:roles"`
Audience []string `json:"aud"`
Issuer string `json:"iss"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
MFAVerified bool `json:"amr"`
}
// TokenResponse represents a token response from Zitadel.
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// UserInfo represents user information from Zitadel.
type UserInfo struct {
ID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Verified bool `json:"email_verified"`
Roles []string `json:"roles"`
}
// ValidateToken validates a JWT token and returns the claims.
// This is a placeholder implementation that should be replaced with actual JWT validation.
func (c *Client) ValidateToken(ctx context.Context, token string) (*AuthClaims, error) {
// TODO: Implement actual JWT validation with JWKS
// This is a placeholder that should:
// 1. Fetch JWKS from Zitadel
// 2. Parse and validate the JWT
// 3. Verify signature, expiration, issuer, audience
// 4. Return the claims
return nil, fmt.Errorf("not implemented: token validation requires JWKS integration")
}
// RefreshToken refreshes an access token using a refresh token.
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
// TODO: Implement token refresh
return nil, fmt.Errorf("not implemented: token refresh")
}
// GetUserInfo retrieves user information using an access token.
func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
url := fmt.Sprintf("%s/oidc/v1/userinfo", c.config.BaseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get user info: status %d", resp.StatusCode)
}
var userInfo UserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, fmt.Errorf("failed to decode user info: %w", err)
}
return &userInfo, nil
}
// RevokeToken revokes a token.
func (c *Client) RevokeToken(ctx context.Context, token string) error {
// TODO: Implement token revocation
return fmt.Errorf("not implemented: token revocation")
}

@ -0,0 +1,164 @@
// Package zitadel provides Zitadel authentication client for the KnowFoolery application.
package zitadel
import (
"context"
"strings"
"github.com/gofiber/fiber/v3"
)
// ContextKey represents a context key for authentication values.
type ContextKey string
const (
// ContextKeyUserID is the context key for the user ID.
ContextKeyUserID ContextKey = "user_id"
// ContextKeyUserEmail is the context key for the user email.
ContextKeyUserEmail ContextKey = "user_email"
// ContextKeyUserName is the context key for the user name.
ContextKeyUserName ContextKey = "user_name"
// ContextKeyUserRoles is the context key for the user roles.
ContextKeyUserRoles ContextKey = "user_roles"
// ContextKeyMFAVerified is the context key for MFA verification status.
ContextKeyMFAVerified ContextKey = "mfa_verified"
)
// JWTMiddlewareConfig holds configuration for the JWT middleware.
type JWTMiddlewareConfig struct {
Client *Client
Issuer string
Audience string
RequiredClaims []string
AdminEndpoints []string
SkipPaths []string
}
// JWTMiddleware creates a Fiber middleware for JWT validation.
func JWTMiddleware(config JWTMiddlewareConfig) fiber.Handler {
return func(c fiber.Ctx) error {
// Check if path should be skipped
path := c.Path()
for _, skipPath := range config.SkipPaths {
if strings.HasPrefix(path, skipPath) {
return c.Next()
}
}
// Extract token from Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Authorization header required",
})
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Invalid authorization header format",
})
}
// Validate token
ctx := c.Context()
claims, err := config.Client.ValidateToken(ctx, tokenString)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Invalid token",
"details": err.Error(),
})
}
// Set user context
c.Locals(string(ContextKeyUserID), claims.Subject)
c.Locals(string(ContextKeyUserEmail), claims.Email)
c.Locals(string(ContextKeyUserName), claims.Name)
c.Locals(string(ContextKeyUserRoles), claims.Roles)
c.Locals(string(ContextKeyMFAVerified), claims.MFAVerified)
// Check admin access for admin endpoints
if isAdminEndpoint(path, config.AdminEndpoints) {
if !hasAdminRole(claims.Roles) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": true,
"message": "Admin access required",
})
}
if !claims.MFAVerified {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": true,
"message": "MFA verification required for admin access",
})
}
}
return c.Next()
}
}
// isAdminEndpoint checks if the given path is an admin endpoint.
func isAdminEndpoint(path string, adminEndpoints []string) bool {
for _, adminPath := range adminEndpoints {
if strings.HasPrefix(path, adminPath) {
return true
}
}
return false
}
// hasAdminRole checks if the user has the admin role.
func hasAdminRole(roles []string) bool {
for _, role := range roles {
if role == "admin" {
return true
}
}
return false
}
// GetUserID extracts the user ID from the Fiber context.
func GetUserID(c fiber.Ctx) string {
if userID := c.Locals(string(ContextKeyUserID)); userID != nil {
return userID.(string)
}
return ""
}
// GetUserEmail extracts the user email from the Fiber context.
func GetUserEmail(c fiber.Ctx) string {
if email := c.Locals(string(ContextKeyUserEmail)); email != nil {
return email.(string)
}
return ""
}
// GetUserRoles extracts the user roles from the Fiber context.
func GetUserRoles(c fiber.Ctx) []string {
if roles := c.Locals(string(ContextKeyUserRoles)); roles != nil {
return roles.([]string)
}
return nil
}
// IsMFAVerified checks if MFA has been verified for the current user.
func IsMFAVerified(c fiber.Ctx) bool {
if verified := c.Locals(string(ContextKeyMFAVerified)); verified != nil {
return verified.(bool)
}
return false
}
// GetUserFromContext retrieves user information from a standard context.
func GetUserFromContext(ctx context.Context) (userID, email, name string, roles []string, ok bool) {
userID, _ = ctx.Value(ContextKeyUserID).(string)
email, _ = ctx.Value(ContextKeyUserEmail).(string)
name, _ = ctx.Value(ContextKeyUserName).(string)
roles, _ = ctx.Value(ContextKeyUserRoles).([]string)
ok = userID != ""
return
}

@ -0,0 +1,98 @@
// Package postgres provides PostgreSQL database client for the KnowFoolery application.
package postgres
import (
"context"
"fmt"
"time"
)
// Config holds the configuration for the PostgreSQL client.
type Config struct {
Host string
Port int
User string
Password string
Database string
SSLMode string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
}
// DefaultConfig returns a default configuration for development.
func DefaultConfig() Config {
return Config{
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "postgres",
Database: "knowfoolery",
SSLMode: "disable",
MaxOpenConns: 25,
MaxIdleConns: 10,
ConnMaxLifetime: 5 * time.Minute,
ConnMaxIdleTime: 1 * time.Minute,
}
}
// DSN returns the PostgreSQL connection string.
func (c Config) DSN() string {
return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
c.Host, c.Port, c.User, c.Password, c.Database, c.SSLMode,
)
}
// URL returns the PostgreSQL connection URL.
func (c Config) URL() string {
return fmt.Sprintf(
"postgresql://%s:%s@%s:%d/%s?sslmode=%s",
c.User, c.Password, c.Host, c.Port, c.Database, c.SSLMode,
)
}
// Client wraps database operations.
// This is a placeholder that should be implemented with actual database client.
type Client struct {
config Config
}
// NewClient creates a new PostgreSQL client.
// Note: Actual implementation should use Ent client from each service.
func NewClient(config Config) (*Client, error) {
// This is a placeholder. The actual Ent client should be created
// in each service's infrastructure layer.
return &Client{
config: config,
}, nil
}
// Close closes the database connection.
func (c *Client) Close() error {
// Placeholder for closing database connection
return nil
}
// Ping checks if the database connection is alive.
func (c *Client) Ping(ctx context.Context) error {
// Placeholder for ping implementation
return nil
}
// HealthCheck performs a health check on the database.
func (c *Client) HealthCheck(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return c.Ping(ctx)
}
// ConfigFromEnv creates a Config from environment variables.
// This is a placeholder that should be implemented with actual env parsing.
func ConfigFromEnv() Config {
// TODO: Implement environment variable parsing
// Should use os.Getenv or a configuration library
return DefaultConfig()
}

@ -0,0 +1,119 @@
// Package redis provides Redis client for the KnowFoolery application.
package redis
import (
"context"
"fmt"
"time"
)
// Config holds the configuration for the Redis client.
type Config struct {
Host string
Port int
Password string
DB int
PoolSize int
MinIdleConns int
DialTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
}
// DefaultConfig returns a default configuration for development.
func DefaultConfig() Config {
return Config{
Host: "localhost",
Port: 6379,
Password: "",
DB: 0,
PoolSize: 10,
MinIdleConns: 5,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
}
}
// Addr returns the Redis server address.
func (c Config) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
// Client wraps Redis operations.
// This is a placeholder that should be replaced with an actual Redis client.
type Client struct {
config Config
}
// NewClient creates a new Redis client.
// Note: Actual implementation should use go-redis/redis.
func NewClient(config Config) (*Client, error) {
// This is a placeholder. The actual Redis client should be created
// using github.com/go-redis/redis/v9
return &Client{
config: config,
}, nil
}
// Close closes the Redis connection.
func (c *Client) Close() error {
// Placeholder for closing Redis connection
return nil
}
// Ping checks if the Redis connection is alive.
func (c *Client) Ping(ctx context.Context) error {
// Placeholder for ping implementation
return nil
}
// HealthCheck performs a health check on Redis.
func (c *Client) HealthCheck(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return c.Ping(ctx)
}
// Set stores a key-value pair with expiration.
func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
// Placeholder for set implementation
return fmt.Errorf("not implemented")
}
// Get retrieves a value by key.
func (c *Client) Get(ctx context.Context, key string) (string, error) {
// Placeholder for get implementation
return "", fmt.Errorf("not implemented")
}
// Delete removes a key.
func (c *Client) Delete(ctx context.Context, keys ...string) error {
// Placeholder for delete implementation
return fmt.Errorf("not implemented")
}
// Exists checks if a key exists.
func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
// Placeholder for exists implementation
return false, fmt.Errorf("not implemented")
}
// Incr increments a counter.
func (c *Client) Incr(ctx context.Context, key string) (int64, error) {
// Placeholder for incr implementation
return 0, fmt.Errorf("not implemented")
}
// Expire sets expiration on a key.
func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) error {
// Placeholder for expire implementation
return fmt.Errorf("not implemented")
}
// ConfigFromEnv creates a Config from environment variables.
func ConfigFromEnv() Config {
// TODO: Implement environment variable parsing
return DefaultConfig()
}

@ -0,0 +1,192 @@
// Package logging provides structured logging for the KnowFoolery application.
package logging
import (
"io"
"os"
"time"
"github.com/rs/zerolog"
)
// Config holds the configuration for the logger.
type Config struct {
Level string
Environment string
ServiceName string
Version string
Output io.Writer
}
// DefaultConfig returns a default configuration.
func DefaultConfig() Config {
return Config{
Level: "info",
Environment: "development",
ServiceName: "knowfoolery",
Version: "0.0.0",
Output: os.Stdout,
}
}
// Logger wraps zerolog.Logger with application-specific methods.
type Logger struct {
logger zerolog.Logger
}
// NewLogger creates a new Logger.
func NewLogger(config Config) *Logger {
// Parse log level
level, err := zerolog.ParseLevel(config.Level)
if err != nil {
level = zerolog.InfoLevel
}
// Set global level
zerolog.SetGlobalLevel(level)
zerolog.TimeFieldFormat = time.RFC3339Nano
var logger zerolog.Logger
output := config.Output
if output == nil {
output = os.Stdout
}
if config.Environment == "development" {
// Human-readable console output for development
logger = zerolog.New(zerolog.ConsoleWriter{
Out: output,
TimeFormat: "15:04:05",
}).With().Timestamp().Logger()
} else {
// JSON output for production
logger = zerolog.New(output).With().Timestamp().Logger()
}
// Add service metadata
logger = logger.With().
Str("service", config.ServiceName).
Str("version", config.Version).
Str("environment", config.Environment).
Logger()
return &Logger{logger: logger}
}
// Debug logs a debug message.
func (l *Logger) Debug(msg string) {
l.logger.Debug().Msg(msg)
}
// Info logs an info message.
func (l *Logger) Info(msg string) {
l.logger.Info().Msg(msg)
}
// Warn logs a warning message.
func (l *Logger) Warn(msg string) {
l.logger.Warn().Msg(msg)
}
// Error logs an error message.
func (l *Logger) Error(msg string) {
l.logger.Error().Msg(msg)
}
// Fatal logs a fatal message and exits.
func (l *Logger) Fatal(msg string) {
l.logger.Fatal().Msg(msg)
}
// WithError adds an error to the log entry.
func (l *Logger) WithError(err error) *Logger {
return &Logger{
logger: l.logger.With().Err(err).Logger(),
}
}
// WithField adds a field to the log entry.
func (l *Logger) WithField(key string, value interface{}) *Logger {
return &Logger{
logger: l.logger.With().Interface(key, value).Logger(),
}
}
// WithFields adds multiple fields to the log entry.
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
ctx := l.logger.With()
for k, v := range fields {
ctx = ctx.Interface(k, v)
}
return &Logger{
logger: ctx.Logger(),
}
}
// GameEvent logs a game-related event.
func (l *Logger) GameEvent(event string, gameSessionID, userID string, properties map[string]interface{}) {
l.logger.Info().
Str("event_type", "game").
Str("event", event).
Str("game_session_id", gameSessionID).
Str("user_id", userID).
Fields(properties).
Msg("Game event occurred")
}
// APIRequest logs an API request.
func (l *Logger) APIRequest(method, path string, statusCode int, duration time.Duration, userID string) {
l.logger.Info().
Str("event_type", "api_request").
Str("method", method).
Str("path", path).
Int("status_code", statusCode).
Dur("duration_ms", duration).
Str("user_id", userID).
Msg("API request processed")
}
// DatabaseOperation logs a database operation.
func (l *Logger) DatabaseOperation(operation, table string, duration time.Duration, rowsAffected int64) {
l.logger.Debug().
Str("event_type", "database").
Str("operation", operation).
Str("table", table).
Dur("duration_ms", duration).
Int64("rows_affected", rowsAffected).
Msg("Database operation completed")
}
// AuthenticationEvent logs an authentication event.
func (l *Logger) AuthenticationEvent(event, userID, userType string, success bool, details map[string]string) {
logEntry := l.logger.Info()
if !success {
logEntry = l.logger.Warn()
}
logEntry.
Str("event_type", "authentication").
Str("event", event).
Str("user_id", userID).
Str("user_type", userType).
Bool("success", success).
Fields(details).
Msg("Authentication event")
}
// SecurityEvent logs a security event.
func (l *Logger) SecurityEvent(event, userID, ipAddress string, severity string, details map[string]interface{}) {
l.logger.Warn().
Str("event_type", "security").
Str("event", event).
Str("user_id", userID).
Str("ip_address", ipAddress).
Str("severity", severity).
Fields(details).
Msg("Security event detected")
}
// Zerolog returns the underlying zerolog.Logger for advanced usage.
func (l *Logger) Zerolog() zerolog.Logger {
return l.logger
}

@ -0,0 +1,202 @@
// Package metrics provides Prometheus metrics for the KnowFoolery application.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Config holds the configuration for metrics.
type Config struct {
ServiceName string
Enabled bool
}
// DefaultConfig returns a default configuration.
func DefaultConfig() Config {
return Config{
ServiceName: "knowfoolery",
Enabled: true,
}
}
// Metrics holds all Prometheus metrics for the application.
type Metrics struct {
config Config
// HTTP metrics
HTTPRequestsTotal *prometheus.CounterVec
HTTPRequestDuration *prometheus.HistogramVec
// Database metrics
DBConnectionsActive *prometheus.GaugeVec
DBQueryDuration *prometheus.HistogramVec
DBErrors *prometheus.CounterVec
// Cache metrics
CacheOperations *prometheus.CounterVec
CacheKeyCount *prometheus.GaugeVec
// Authentication metrics
AuthAttempts *prometheus.CounterVec
TokenOperations *prometheus.CounterVec
// Game metrics
GamesStarted *prometheus.CounterVec
GamesCompleted *prometheus.CounterVec
SessionDuration *prometheus.HistogramVec
QuestionsAsked *prometheus.CounterVec
AnswersSubmitted *prometheus.CounterVec
HintsRequested *prometheus.CounterVec
ScoreDistribution *prometheus.HistogramVec
}
// NewMetrics creates a new Metrics instance with all metrics registered.
func NewMetrics(config Config) *Metrics {
m := &Metrics{config: config}
// HTTP metrics
m.HTTPRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "endpoint", "status_code", "service"},
)
m.HTTPRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "endpoint", "service"},
)
// Database metrics
m.DBConnectionsActive = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "db_connections_active",
Help: "Number of active database connections",
},
[]string{"database", "service"},
)
m.DBQueryDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "db_query_duration_seconds",
Help: "Database query duration",
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0},
},
[]string{"query_type", "table", "service"},
)
m.DBErrors = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "db_errors_total",
Help: "Total number of database errors",
},
[]string{"error_type", "service"},
)
// Cache metrics
m.CacheOperations = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cache_operations_total",
Help: "Total number of cache operations",
},
[]string{"operation", "result", "service"},
)
m.CacheKeyCount = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "cache_keys_total",
Help: "Number of keys in cache",
},
[]string{"cache_type", "service"},
)
// Authentication metrics
m.AuthAttempts = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "authentication_attempts_total",
Help: "Total authentication attempts",
},
[]string{"method", "result", "user_type"},
)
m.TokenOperations = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "token_operations_total",
Help: "JWT token operations",
},
[]string{"operation", "result"},
)
// Game metrics
m.GamesStarted = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "games_started_total",
Help: "Total number of games started",
},
[]string{"player_type", "platform"},
)
m.GamesCompleted = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "games_completed_total",
Help: "Total number of games completed",
},
[]string{"completion_type", "platform"},
)
m.SessionDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "game_session_duration_seconds",
Help: "Duration of game sessions",
Buckets: []float64{60, 300, 600, 900, 1200, 1500, 1800},
},
[]string{"completion_type"},
)
m.QuestionsAsked = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "questions_asked_total",
Help: "Total number of questions asked",
},
[]string{"theme", "difficulty"},
)
m.AnswersSubmitted = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "answers_submitted_total",
Help: "Total number of answers submitted",
},
[]string{"theme", "is_correct", "attempt_number", "used_hint"},
)
m.HintsRequested = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "hints_requested_total",
Help: "Total number of hints requested",
},
[]string{"theme", "question_difficulty"},
)
m.ScoreDistribution = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "game_scores",
Help: "Distribution of game scores",
Buckets: []float64{0, 5, 10, 15, 20, 25, 30, 40, 50, 60, 80, 100},
},
[]string{"session_duration_bucket"},
)
return m
}
// ConfigFromEnv creates a Config from environment variables.
func ConfigFromEnv() Config {
// TODO: Implement environment variable parsing
return DefaultConfig()
}

@ -0,0 +1,123 @@
// Package tracing provides distributed tracing for the KnowFoolery application.
package tracing
import (
"context"
"fmt"
)
// Config holds the configuration for the tracer.
type Config struct {
ServiceName string
ServiceVersion string
Environment string
JaegerEndpoint string
SampleRate float64
Enabled bool
}
// DefaultConfig returns a default configuration.
func DefaultConfig() Config {
return Config{
ServiceName: "knowfoolery",
ServiceVersion: "0.0.0",
Environment: "development",
JaegerEndpoint: "http://localhost:14268/api/traces",
SampleRate: 1.0, // Sample all traces in development
Enabled: false,
}
}
// Tracer provides distributed tracing functionality.
// This is a placeholder that should be implemented with OpenTelemetry.
type Tracer struct {
config Config
}
// NewTracer creates a new Tracer.
// Note: Actual implementation should use OpenTelemetry SDK.
func NewTracer(config Config) (*Tracer, error) {
if !config.Enabled {
return &Tracer{config: config}, nil
}
// TODO: Initialize OpenTelemetry tracer provider
// Example implementation:
// - Create Jaeger exporter
// - Create tracer provider with batching
// - Set global tracer provider
// - Configure resource with service info
return &Tracer{config: config}, nil
}
// Shutdown gracefully shuts down the tracer.
func (t *Tracer) Shutdown(ctx context.Context) error {
if !t.config.Enabled {
return nil
}
// TODO: Shutdown tracer provider
return nil
}
// StartSpan starts a new span.
// This is a placeholder that should be implemented with OpenTelemetry.
func (t *Tracer) StartSpan(ctx context.Context, name string) (context.Context, Span) {
// TODO: Start actual OpenTelemetry span
return ctx, &noopSpan{}
}
// Span represents a tracing span.
type Span interface {
// End ends the span.
End()
// SetAttribute sets an attribute on the span.
SetAttribute(key string, value interface{})
// RecordError records an error on the span.
RecordError(err error)
}
// noopSpan is a no-op implementation of Span.
type noopSpan struct{}
func (s *noopSpan) End() {}
func (s *noopSpan) SetAttribute(key string, value interface{}) {}
func (s *noopSpan) RecordError(err error) {}
// TraceServiceOperation traces a service operation.
func TraceServiceOperation(ctx context.Context, tracer *Tracer, serviceName, operation string, fn func(context.Context) error) error {
ctx, span := tracer.StartSpan(ctx, fmt.Sprintf("%s.%s", serviceName, operation))
defer span.End()
err := fn(ctx)
if err != nil {
span.RecordError(err)
span.SetAttribute("error", true)
}
return err
}
// TraceDatabaseOperation traces a database operation.
func TraceDatabaseOperation(ctx context.Context, tracer *Tracer, operation, table string, fn func(context.Context) error) error {
ctx, span := tracer.StartSpan(ctx, fmt.Sprintf("db.%s.%s", operation, table))
defer span.End()
span.SetAttribute("db.operation", operation)
span.SetAttribute("db.table", table)
span.SetAttribute("db.system", "postgresql")
err := fn(ctx)
if err != nil {
span.RecordError(err)
}
return err
}
// ConfigFromEnv creates a Config from environment variables.
func ConfigFromEnv() Config {
// TODO: Implement environment variable parsing
return DefaultConfig()
}

@ -0,0 +1,172 @@
// Package security provides security utilities for the KnowFoolery application.
package security
import (
"html"
"regexp"
"strings"
"unicode"
)
// SanitizeOptions configures sanitization behavior.
type SanitizeOptions struct {
TrimWhitespace bool
RemoveMultipleSpaces bool
HTMLEscape bool
MaxLength int
AllowedPattern *regexp.Regexp
}
// DefaultSanitizeOptions returns default sanitization options.
func DefaultSanitizeOptions() SanitizeOptions {
return SanitizeOptions{
TrimWhitespace: true,
RemoveMultipleSpaces: true,
HTMLEscape: true,
MaxLength: 0, // No limit
}
}
// Sanitize sanitizes a string according to the given options.
func Sanitize(input string, opts SanitizeOptions) string {
result := input
// Trim whitespace
if opts.TrimWhitespace {
result = strings.TrimSpace(result)
}
// Remove multiple consecutive spaces
if opts.RemoveMultipleSpaces {
spaceRegex := regexp.MustCompile(`\s+`)
result = spaceRegex.ReplaceAllString(result, " ")
}
// HTML escape
if opts.HTMLEscape {
result = html.EscapeString(result)
}
// Apply maximum length
if opts.MaxLength > 0 && len(result) > opts.MaxLength {
result = result[:opts.MaxLength]
}
// Validate against allowed pattern
if opts.AllowedPattern != nil && !opts.AllowedPattern.MatchString(result) {
return ""
}
return result
}
// SanitizePlayerName sanitizes a player name.
func SanitizePlayerName(input string) string {
opts := SanitizeOptions{
TrimWhitespace: true,
RemoveMultipleSpaces: true,
HTMLEscape: true,
MaxLength: 50,
AllowedPattern: regexp.MustCompile(`^[a-zA-Z0-9\s\-_.]+$`),
}
return Sanitize(input, opts)
}
// SanitizeAnswer sanitizes an answer submission.
func SanitizeAnswer(input string) string {
opts := SanitizeOptions{
TrimWhitespace: true,
RemoveMultipleSpaces: true,
HTMLEscape: true,
MaxLength: 500,
}
result := Sanitize(input, opts)
// Normalize to lowercase for comparison
result = strings.ToLower(result)
return result
}
// SanitizeQuestionText sanitizes question text (admin input).
func SanitizeQuestionText(input string) string {
opts := SanitizeOptions{
TrimWhitespace: true,
RemoveMultipleSpaces: true,
HTMLEscape: true,
MaxLength: 1000,
}
result := Sanitize(input, opts)
// Remove potential script content
scriptRegex := regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`)
result = scriptRegex.ReplaceAllString(result, "")
return result
}
// SanitizeTheme sanitizes a theme name.
func SanitizeTheme(input string) string {
opts := SanitizeOptions{
TrimWhitespace: true,
RemoveMultipleSpaces: true,
HTMLEscape: true,
MaxLength: 100,
AllowedPattern: regexp.MustCompile(`^[a-zA-Z0-9\s\-_]+$`),
}
result := Sanitize(input, opts)
// Title case
words := strings.Fields(result)
for i, word := range words {
if len(word) > 0 {
runes := []rune(word)
runes[0] = unicode.ToUpper(runes[0])
for j := 1; j < len(runes); j++ {
runes[j] = unicode.ToLower(runes[j])
}
words[i] = string(runes)
}
}
return strings.Join(words, " ")
}
// RemoveHTMLTags removes all HTML tags from a string.
func RemoveHTMLTags(input string) string {
tagRegex := regexp.MustCompile(`<[^>]*>`)
return tagRegex.ReplaceAllString(input, "")
}
// ContainsDangerousPatterns checks if input contains potentially dangerous patterns.
func ContainsDangerousPatterns(input string) bool {
dangerousPatterns := []string{
"javascript:",
"data:",
"vbscript:",
"<script",
"</script",
"onerror",
"onload",
"onclick",
"onmouseover",
}
lowerInput := strings.ToLower(input)
for _, pattern := range dangerousPatterns {
if strings.Contains(lowerInput, pattern) {
return true
}
}
return false
}
// IsValidEmail performs basic email validation.
func IsValidEmail(email string) bool {
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return emailRegex.MatchString(email)
}

@ -0,0 +1,147 @@
// Package httputil provides HTTP utility functions for the KnowFoolery application.
package httputil
import (
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/domain/errors"
)
// ErrorResponse represents a standard error response.
type ErrorResponse struct {
Error bool `json:"error"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// NewErrorResponse creates a new ErrorResponse.
func NewErrorResponse(code, message, details string) ErrorResponse {
return ErrorResponse{
Error: true,
Code: code,
Message: message,
Details: details,
}
}
// SendError sends an error response with the appropriate HTTP status code.
func SendError(c fiber.Ctx, err error) error {
statusCode, response := MapError(err)
return c.Status(statusCode).JSON(response)
}
// MapError maps a domain error to an HTTP status code and response.
func MapError(err error) (int, ErrorResponse) {
if err == nil {
return fiber.StatusInternalServerError, NewErrorResponse(
"INTERNAL",
"An unexpected error occurred",
"",
)
}
domainErr, ok := err.(*errors.DomainError)
if !ok {
return fiber.StatusInternalServerError, NewErrorResponse(
"INTERNAL",
"An unexpected error occurred",
err.Error(),
)
}
statusCode := mapErrorCodeToStatus(domainErr.Code)
return statusCode, NewErrorResponse(
domainErr.Code.String(),
domainErr.Message,
"",
)
}
// mapErrorCodeToStatus maps an error code to an HTTP status code.
func mapErrorCodeToStatus(code errors.ErrorCode) int {
switch code {
case errors.CodeNotFound, errors.CodeQuestionNotFound, errors.CodeUserNotFound:
return fiber.StatusNotFound
case errors.CodeInvalidInput, errors.CodeValidationFailed, errors.CodeInvalidPlayerName, errors.CodeInvalidAnswer:
return fiber.StatusBadRequest
case errors.CodeUnauthorized, errors.CodeInvalidToken, errors.CodeTokenExpired:
return fiber.StatusUnauthorized
case errors.CodeForbidden, errors.CodeMFARequired:
return fiber.StatusForbidden
case errors.CodeConflict, errors.CodeUserAlreadyExists, errors.CodeGameInProgress:
return fiber.StatusConflict
case errors.CodeRateLimitExceeded:
return fiber.StatusTooManyRequests
case errors.CodeSessionExpired, errors.CodeSessionNotActive:
return fiber.StatusGone
case errors.CodeMaxAttemptsReached:
return fiber.StatusUnprocessableEntity
default:
return fiber.StatusInternalServerError
}
}
// BadRequest sends a 400 Bad Request response.
func BadRequest(c fiber.Ctx, message string) error {
return c.Status(fiber.StatusBadRequest).JSON(NewErrorResponse(
"BAD_REQUEST",
message,
"",
))
}
// Unauthorized sends a 401 Unauthorized response.
func Unauthorized(c fiber.Ctx, message string) error {
return c.Status(fiber.StatusUnauthorized).JSON(NewErrorResponse(
"UNAUTHORIZED",
message,
"",
))
}
// Forbidden sends a 403 Forbidden response.
func Forbidden(c fiber.Ctx, message string) error {
return c.Status(fiber.StatusForbidden).JSON(NewErrorResponse(
"FORBIDDEN",
message,
"",
))
}
// NotFound sends a 404 Not Found response.
func NotFound(c fiber.Ctx, message string) error {
return c.Status(fiber.StatusNotFound).JSON(NewErrorResponse(
"NOT_FOUND",
message,
"",
))
}
// Conflict sends a 409 Conflict response.
func Conflict(c fiber.Ctx, message string) error {
return c.Status(fiber.StatusConflict).JSON(NewErrorResponse(
"CONFLICT",
message,
"",
))
}
// TooManyRequests sends a 429 Too Many Requests response.
func TooManyRequests(c fiber.Ctx, message string, retryAfter int) error {
c.Set("Retry-After", string(rune(retryAfter)))
return c.Status(fiber.StatusTooManyRequests).JSON(NewErrorResponse(
"RATE_LIMIT_EXCEEDED",
message,
"",
))
}
// InternalError sends a 500 Internal Server Error response.
func InternalError(c fiber.Ctx, message string) error {
return c.Status(fiber.StatusInternalServerError).JSON(NewErrorResponse(
"INTERNAL",
message,
"",
))
}

@ -0,0 +1,120 @@
// Package httputil provides HTTP utility functions for the KnowFoolery application.
package httputil
import (
"strconv"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/domain/types"
)
// PaginationFromQuery extracts pagination parameters from query string.
func PaginationFromQuery(c fiber.Ctx) types.Pagination {
page := c.Query("page", "1")
pageSize := c.Query("page_size", strconv.Itoa(types.DefaultPageSize))
pageNum, err := strconv.Atoi(page)
if err != nil || pageNum < 1 {
pageNum = 1
}
pageSizeNum, err := strconv.Atoi(pageSize)
if err != nil || pageSizeNum < 1 {
pageSizeNum = types.DefaultPageSize
}
if pageSizeNum > types.MaxPageSize {
pageSizeNum = types.MaxPageSize
}
return types.Pagination{
Page: pageNum,
PageSize: pageSizeNum,
}
}
// SortingParams holds sorting parameters.
type SortingParams struct {
Field string
Direction string // "asc" or "desc"
}
// DefaultSorting returns default sorting parameters.
func DefaultSorting(defaultField string) SortingParams {
return SortingParams{
Field: defaultField,
Direction: "asc",
}
}
// SortingFromQuery extracts sorting parameters from query string.
func SortingFromQuery(c fiber.Ctx, defaultField string, allowedFields []string) SortingParams {
sort := c.Query("sort", defaultField)
direction := c.Query("direction", "asc")
// Validate sort field
isAllowed := false
for _, field := range allowedFields {
if field == sort {
isAllowed = true
break
}
}
if !isAllowed {
sort = defaultField
}
// Validate direction
if direction != "asc" && direction != "desc" {
direction = "asc"
}
return SortingParams{
Field: sort,
Direction: direction,
}
}
// FilterParams holds common filter parameters.
type FilterParams struct {
Search string
Status string
DateFrom string
DateTo string
Custom map[string]string
}
// FiltersFromQuery extracts common filter parameters from query string.
func FiltersFromQuery(c fiber.Ctx) FilterParams {
return FilterParams{
Search: c.Query("search"),
Status: c.Query("status"),
DateFrom: c.Query("date_from"),
DateTo: c.Query("date_to"),
Custom: make(map[string]string),
}
}
// WithCustomFilter adds a custom filter parameter.
func (f *FilterParams) WithCustomFilter(c fiber.Ctx, name string) *FilterParams {
if value := c.Query(name); value != "" {
f.Custom[name] = value
}
return f
}
// QueryParams holds all common query parameters.
type QueryParams struct {
Pagination types.Pagination
Sorting SortingParams
Filters FilterParams
}
// QueryParamsFromContext extracts all common query parameters.
func QueryParamsFromContext(c fiber.Ctx, defaultSortField string, allowedSortFields []string) QueryParams {
return QueryParams{
Pagination: PaginationFromQuery(c),
Sorting: SortingFromQuery(c, defaultSortField, allowedSortFields),
Filters: FiltersFromQuery(c),
}
}

@ -0,0 +1,108 @@
// Package httputil provides HTTP utility functions for the KnowFoolery application.
package httputil
import (
"github.com/gofiber/fiber/v3"
)
// Response represents a standard API response.
type Response struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
// Meta contains response metadata.
type Meta struct {
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
TotalCount int64 `json:"total_count,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
}
// NewResponse creates a new Response.
func NewResponse(data interface{}) Response {
return Response{
Success: true,
Data: data,
}
}
// NewPaginatedResponse creates a new paginated Response.
func NewPaginatedResponse(data interface{}, page, pageSize int, totalCount int64) Response {
totalPages := int(totalCount) / pageSize
if int(totalCount)%pageSize > 0 {
totalPages++
}
return Response{
Success: true,
Data: data,
Meta: &Meta{
Page: page,
PageSize: pageSize,
TotalCount: totalCount,
TotalPages: totalPages,
},
}
}
// OK sends a 200 OK response with data.
func OK(c fiber.Ctx, data interface{}) error {
return c.Status(fiber.StatusOK).JSON(NewResponse(data))
}
// Created sends a 201 Created response with data.
func Created(c fiber.Ctx, data interface{}) error {
return c.Status(fiber.StatusCreated).JSON(NewResponse(data))
}
// NoContent sends a 204 No Content response.
func NoContent(c fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
// Paginated sends a paginated response.
func Paginated(c fiber.Ctx, data interface{}, page, pageSize int, totalCount int64) error {
return c.Status(fiber.StatusOK).JSON(NewPaginatedResponse(data, page, pageSize, totalCount))
}
// MessageResponse represents a simple message response.
type MessageResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// Message sends a simple message response.
func Message(c fiber.Ctx, message string) error {
return c.Status(fiber.StatusOK).JSON(MessageResponse{
Success: true,
Message: message,
})
}
// HealthResponse represents a health check response.
type HealthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Checks map[string]string `json:"checks,omitempty"`
}
// Health sends a health check response.
func Health(c fiber.Ctx, service, version string, checks map[string]string) error {
status := "healthy"
for _, check := range checks {
if check != "ok" {
status = "unhealthy"
break
}
}
return c.Status(fiber.StatusOK).JSON(HealthResponse{
Status: status,
Service: service,
Version: version,
Checks: checks,
})
}

@ -0,0 +1,172 @@
// Package validation provides validation utilities for the KnowFoolery application.
package validation
import (
"fmt"
"reflect"
"strings"
"unicode"
"github.com/go-playground/validator/v10"
"knowfoolery/backend/shared/domain/errors"
)
// Validator wraps the go-playground validator with custom validations.
type Validator struct {
validate *validator.Validate
}
// NewValidator creates a new Validator with custom validations registered.
func NewValidator() *Validator {
v := validator.New()
// Use JSON tag names in error messages
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// Register custom validations
v.RegisterValidation("alphanum_space", validateAlphanumSpace)
v.RegisterValidation("no_html", validateNoHTML)
v.RegisterValidation("safe_text", validateSafeText)
v.RegisterValidation("player_name", validatePlayerName)
return &Validator{validate: v}
}
// Validate validates a struct and returns a domain error if validation fails.
func (v *Validator) Validate(s interface{}) error {
err := v.validate.Struct(s)
if err == nil {
return nil
}
validationErrors, ok := err.(validator.ValidationErrors)
if !ok {
return errors.Wrap(errors.CodeValidationFailed, "validation failed", err)
}
messages := make([]string, 0, len(validationErrors))
for _, e := range validationErrors {
messages = append(messages, formatValidationError(e))
}
return errors.Wrap(
errors.CodeValidationFailed,
strings.Join(messages, "; "),
nil,
)
}
// ValidateVar validates a single variable.
func (v *Validator) ValidateVar(field interface{}, tag string) error {
err := v.validate.Var(field, tag)
if err == nil {
return nil
}
return errors.Wrap(errors.CodeValidationFailed, "validation failed", err)
}
// formatValidationError formats a single validation error into a readable message.
func formatValidationError(e validator.FieldError) string {
field := e.Field()
switch e.Tag() {
case "required":
return fmt.Sprintf("%s is required", field)
case "min":
return fmt.Sprintf("%s must be at least %s characters", field, e.Param())
case "max":
return fmt.Sprintf("%s must be at most %s characters", field, e.Param())
case "email":
return fmt.Sprintf("%s must be a valid email", field)
case "alphanum":
return fmt.Sprintf("%s must contain only alphanumeric characters", field)
case "alphanum_space":
return fmt.Sprintf("%s must contain only alphanumeric characters and spaces", field)
case "player_name":
return fmt.Sprintf("%s must be a valid player name (2-50 chars, alphanumeric with spaces)", field)
case "oneof":
return fmt.Sprintf("%s must be one of: %s", field, e.Param())
case "gte":
return fmt.Sprintf("%s must be greater than or equal to %s", field, e.Param())
case "lte":
return fmt.Sprintf("%s must be less than or equal to %s", field, e.Param())
case "uuid":
return fmt.Sprintf("%s must be a valid UUID", field)
default:
return fmt.Sprintf("%s is invalid", field)
}
}
// Custom validation functions
// validateAlphanumSpace validates that a string contains only alphanumeric characters and spaces.
func validateAlphanumSpace(fl validator.FieldLevel) bool {
str := fl.Field().String()
for _, r := range str {
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) && r != '-' && r != '_' && r != '.' {
return false
}
}
return true
}
// validateNoHTML validates that a string contains no HTML tags.
func validateNoHTML(fl validator.FieldLevel) bool {
str := fl.Field().String()
return !strings.Contains(str, "<") && !strings.Contains(str, ">")
}
// validateSafeText validates that a string contains no potentially dangerous patterns.
func validateSafeText(fl validator.FieldLevel) bool {
str := strings.ToLower(fl.Field().String())
dangerousPatterns := []string{
"javascript:",
"data:",
"vbscript:",
"<script",
"</script",
}
for _, pattern := range dangerousPatterns {
if strings.Contains(str, pattern) {
return false
}
}
return true
}
// validatePlayerName validates a player name format.
func validatePlayerName(fl validator.FieldLevel) bool {
str := fl.Field().String()
// Length check
if len(str) < 2 || len(str) > 50 {
return false
}
// Character validation
for _, r := range str {
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) && r != '-' && r != '_' && r != '.' {
return false
}
}
return true
}
// Common validation tags for reuse
const (
TagRequired = "required"
TagPlayerName = "required,player_name"
TagEmail = "required,email"
TagUUID = "required,uuid"
TagOptionalUUID = "omitempty,uuid"
)
Loading…
Cancel
Save