From b97644a540fa294e36810ca9f6e7a6041faa2377 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sat, 31 Jan 2026 12:55:26 +0100 Subject: [PATCH] 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 --- backend/go.work.sum | 42 ++++ backend/shared/domain/errors/codes.go | 49 +++++ backend/shared/domain/errors/errors.go | 83 +++++++ backend/shared/domain/events/contracts.go | 54 +++++ backend/shared/domain/events/event.go | 73 +++++++ backend/shared/domain/types/enums.go | 79 +++++++ backend/shared/domain/types/id.go | 50 +++++ backend/shared/domain/types/pagination.go | 90 ++++++++ .../shared/domain/valueobjects/player_name.go | 77 +++++++ backend/shared/domain/valueobjects/score.go | 91 ++++++++ backend/shared/go.mod | 34 +++ backend/shared/go.sum | 77 +++++++ backend/shared/infra/auth/rbac/roles.go | 157 ++++++++++++++ backend/shared/infra/auth/zitadel/client.go | 143 +++++++++++++ .../shared/infra/auth/zitadel/middleware.go | 164 ++++++++++++++ .../shared/infra/database/postgres/client.go | 98 +++++++++ backend/shared/infra/database/redis/client.go | 119 +++++++++++ .../infra/observability/logging/logger.go | 192 +++++++++++++++++ .../infra/observability/metrics/prometheus.go | 202 ++++++++++++++++++ .../infra/observability/tracing/tracer.go | 123 +++++++++++ backend/shared/infra/security/sanitize.go | 172 +++++++++++++++ backend/shared/infra/utils/httputil/errors.go | 147 +++++++++++++ .../shared/infra/utils/httputil/pagination.go | 120 +++++++++++ .../shared/infra/utils/httputil/response.go | 108 ++++++++++ .../infra/utils/validation/validator.go | 172 +++++++++++++++ 25 files changed, 2716 insertions(+) create mode 100644 backend/shared/domain/errors/codes.go create mode 100644 backend/shared/domain/errors/errors.go create mode 100644 backend/shared/domain/events/contracts.go create mode 100644 backend/shared/domain/events/event.go create mode 100644 backend/shared/domain/types/enums.go create mode 100644 backend/shared/domain/types/id.go create mode 100644 backend/shared/domain/types/pagination.go create mode 100644 backend/shared/domain/valueobjects/player_name.go create mode 100644 backend/shared/domain/valueobjects/score.go create mode 100644 backend/shared/go.sum create mode 100644 backend/shared/infra/auth/rbac/roles.go create mode 100644 backend/shared/infra/auth/zitadel/client.go create mode 100644 backend/shared/infra/auth/zitadel/middleware.go create mode 100644 backend/shared/infra/database/postgres/client.go create mode 100644 backend/shared/infra/database/redis/client.go create mode 100644 backend/shared/infra/observability/logging/logger.go create mode 100644 backend/shared/infra/observability/metrics/prometheus.go create mode 100644 backend/shared/infra/observability/tracing/tracer.go create mode 100644 backend/shared/infra/security/sanitize.go create mode 100644 backend/shared/infra/utils/httputil/errors.go create mode 100644 backend/shared/infra/utils/httputil/pagination.go create mode 100644 backend/shared/infra/utils/httputil/response.go create mode 100644 backend/shared/infra/utils/validation/validator.go diff --git a/backend/go.work.sum b/backend/go.work.sum index b8c077f..407074b 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -1,17 +1,59 @@ 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/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/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/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-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/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/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/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/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/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= 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.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= diff --git a/backend/shared/domain/errors/codes.go b/backend/shared/domain/errors/codes.go new file mode 100644 index 0000000..7e8ee05 --- /dev/null +++ b/backend/shared/domain/errors/codes.go @@ -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) +} diff --git a/backend/shared/domain/errors/errors.go b/backend/shared/domain/errors/errors.go new file mode 100644 index 0000000..3aef612 --- /dev/null +++ b/backend/shared/domain/errors/errors.go @@ -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") +) diff --git a/backend/shared/domain/events/contracts.go b/backend/shared/domain/events/contracts.go new file mode 100644 index 0000000..689ac5e --- /dev/null +++ b/backend/shared/domain/events/contracts.go @@ -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) +} diff --git a/backend/shared/domain/events/event.go b/backend/shared/domain/events/event.go new file mode 100644 index 0000000..ffd802d --- /dev/null +++ b/backend/shared/domain/events/event.go @@ -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 +} diff --git a/backend/shared/domain/types/enums.go b/backend/shared/domain/types/enums.go new file mode 100644 index 0000000..28df70e --- /dev/null +++ b/backend/shared/domain/types/enums.go @@ -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) +} diff --git a/backend/shared/domain/types/id.go b/backend/shared/domain/types/id.go new file mode 100644 index 0000000..1305b02 --- /dev/null +++ b/backend/shared/domain/types/id.go @@ -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 diff --git a/backend/shared/domain/types/pagination.go b/backend/shared/domain/types/pagination.go new file mode 100644 index 0000000..7ebcdbf --- /dev/null +++ b/backend/shared/domain/types/pagination.go @@ -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 +} diff --git a/backend/shared/domain/valueobjects/player_name.go b/backend/shared/domain/valueobjects/player_name.go new file mode 100644 index 0000000..fe24eb2 --- /dev/null +++ b/backend/shared/domain/valueobjects/player_name.go @@ -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 == "" +} diff --git a/backend/shared/domain/valueobjects/score.go b/backend/shared/domain/valueobjects/score.go new file mode 100644 index 0000000..2f62547 --- /dev/null +++ b/backend/shared/domain/valueobjects/score.go @@ -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 +} diff --git a/backend/shared/go.mod b/backend/shared/go.mod index e808c74..efcee3c 100644 --- a/backend/shared/go.mod +++ b/backend/shared/go.mod @@ -1,3 +1,37 @@ module knowfoolery/backend/shared 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 +) diff --git a/backend/shared/go.sum b/backend/shared/go.sum new file mode 100644 index 0000000..936cfee --- /dev/null +++ b/backend/shared/go.sum @@ -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= diff --git a/backend/shared/infra/auth/rbac/roles.go b/backend/shared/infra/auth/rbac/roles.go new file mode 100644 index 0000000..f66afdc --- /dev/null +++ b/backend/shared/infra/auth/rbac/roles.go @@ -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 +} diff --git a/backend/shared/infra/auth/zitadel/client.go b/backend/shared/infra/auth/zitadel/client.go new file mode 100644 index 0000000..6cedeb3 --- /dev/null +++ b/backend/shared/infra/auth/zitadel/client.go @@ -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") +} diff --git a/backend/shared/infra/auth/zitadel/middleware.go b/backend/shared/infra/auth/zitadel/middleware.go new file mode 100644 index 0000000..12ad40e --- /dev/null +++ b/backend/shared/infra/auth/zitadel/middleware.go @@ -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 +} diff --git a/backend/shared/infra/database/postgres/client.go b/backend/shared/infra/database/postgres/client.go new file mode 100644 index 0000000..d779aed --- /dev/null +++ b/backend/shared/infra/database/postgres/client.go @@ -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() +} diff --git a/backend/shared/infra/database/redis/client.go b/backend/shared/infra/database/redis/client.go new file mode 100644 index 0000000..c0e3bf0 --- /dev/null +++ b/backend/shared/infra/database/redis/client.go @@ -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() +} diff --git a/backend/shared/infra/observability/logging/logger.go b/backend/shared/infra/observability/logging/logger.go new file mode 100644 index 0000000..9d95748 --- /dev/null +++ b/backend/shared/infra/observability/logging/logger.go @@ -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 +} diff --git a/backend/shared/infra/observability/metrics/prometheus.go b/backend/shared/infra/observability/metrics/prometheus.go new file mode 100644 index 0000000..cbb0b4b --- /dev/null +++ b/backend/shared/infra/observability/metrics/prometheus.go @@ -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() +} diff --git a/backend/shared/infra/observability/tracing/tracer.go b/backend/shared/infra/observability/tracing/tracer.go new file mode 100644 index 0000000..655351c --- /dev/null +++ b/backend/shared/infra/observability/tracing/tracer.go @@ -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() +} diff --git a/backend/shared/infra/security/sanitize.go b/backend/shared/infra/security/sanitize.go new file mode 100644 index 0000000..79ef297 --- /dev/null +++ b/backend/shared/infra/security/sanitize.go @@ -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)]*>.*?`) + 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:", + " 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), + } +} diff --git a/backend/shared/infra/utils/httputil/response.go b/backend/shared/infra/utils/httputil/response.go new file mode 100644 index 0000000..9eb225c --- /dev/null +++ b/backend/shared/infra/utils/httputil/response.go @@ -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, + }) +} diff --git a/backend/shared/infra/utils/validation/validator.go b/backend/shared/infra/utils/validation/validator.go new file mode 100644 index 0000000..db206ca --- /dev/null +++ b/backend/shared/infra/utils/validation/validator.go @@ -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:", + " 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" +)