diff --git a/.env.dev.example b/.env.dev.example index cf44a7d..3d5da9f 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -14,13 +14,13 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000 MIGRATIONS_DIR=migrations -POSTGRES_HOST=gamidoc-backend-dev-pg +POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=gamidoc POSTGRES_USER=gamidoc POSTGRES_PASSWORD=gamidoc -REDIS_HOST=gamidoc-backend-dev-redis +REDIS_HOST=localhost REDIS_PORT=6379 JWT_SECRET=dev-secret diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a57460a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.23-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o gamidoc-backend ./cmd/gamidoc-backend/ + +FROM alpine:3.21 +WORKDIR /app +COPY --from=builder /app/gamidoc-backend . +COPY --from=builder /app/migrations ./migrations +COPY --from=builder /app/rule ./rule +EXPOSE 8080 +CMD ["sh", "-c", "./gamidoc-backend migrate up && ./gamidoc-backend serve"] diff --git a/config/config.go b/config/config.go index 58819bb..ce23bdd 100644 --- a/config/config.go +++ b/config/config.go @@ -29,13 +29,18 @@ type Config struct { PostgresDB string PostgresUser string PostgresPassword string + PostgresSSLMode string - RedisHost string - RedisPort string + RedisHost string + RedisPort string + RedisPassword string + RedisTLS bool JWTSecret string JWTExpiresIn time.Duration + RefreshTokenTTL time.Duration + SessionTTL time.Duration ObjectStorageProvider string @@ -58,7 +63,8 @@ type Config struct { } func Load() Config { - expiresIn := parseDurationWithFallback(getEnv("JWT_EXPIRES_IN", "24h"), 24*time.Hour) + expiresIn := parseDurationWithFallback(getEnv("JWT_EXPIRES_IN", "15m"), 15*time.Minute) + refreshTTL := parseDurationWithFallback(getEnv("REFRESH_TOKEN_TTL", "168h"), 168*time.Hour) sessionTTL := parseDurationWithFallback(getEnv("SESSION_TTL", "48h"), 48*time.Hour) return Config{ @@ -77,10 +83,14 @@ func Load() Config { PostgresDB: getEnv("POSTGRES_DB", "gamidoc"), PostgresUser: getEnv("POSTGRES_USER", "gamidoc"), PostgresPassword: getEnv("POSTGRES_PASSWORD", "gamidoc"), + PostgresSSLMode: getEnv("POSTGRES_SSLMODE", "disable"), RedisHost: getEnv("REDIS_HOST", "localhost"), RedisPort: getEnv("REDIS_PORT", "6379"), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisTLS: getEnvBool("REDIS_TLS", false), JWTSecret: getEnv("JWT_SECRET", "dev-secret"), JWTExpiresIn: expiresIn, + RefreshTokenTTL: refreshTTL, SessionTTL: sessionTTL, ObjectStorageProvider: getEnv("OBJECT_STORAGE_PROVIDER", "local"), ObjectStoragePublicBaseURL: getEnv("OBJECT_STORAGE_PUBLIC_BASE_URL", getEnv("PDF_BASE_URL", "/files/pdfs")), @@ -156,23 +166,25 @@ func (c Config) ValidateCore() error { func (c Config) PostgresDSN() string { return fmt.Sprintf( - "host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", + "host=%s port=%s dbname=%s user=%s password=%s sslmode=%s", c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresUser, c.PostgresPassword, + c.PostgresSSLMode, ) } func (c Config) PostgresURL() string { return fmt.Sprintf( - "postgresql://%s:%s@%s:%s/%s?sslmode=disable", + "postgresql://%s:%s@%s:%s/%s?sslmode=%s", c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, + c.PostgresSSLMode, ) } diff --git a/gamidoc-backend.exe b/gamidoc-backend.exe new file mode 100644 index 0000000..a92413b Binary files /dev/null and b/gamidoc-backend.exe differ diff --git a/go.mod b/go.mod index 0ccdbfd..031f636 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/gamidoc/backend go 1.26.1 require ( + github.com/alicebob/miniredis/v2 v2.38.0 github.com/aws/aws-sdk-go-v2/config v1.32.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.15 github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 @@ -36,5 +37,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 7eacf4a..f9cced4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= @@ -71,6 +73,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= diff --git a/internal/app/app.go b/internal/app/app.go index 38b5d30..68aa105 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "strings" "github.com/gamidoc/backend/config" "github.com/gamidoc/backend/internal/auth" @@ -41,7 +42,7 @@ func New(cfg config.Config) (*App, error) { return nil, err } - redisClient := rediscache.New(cfg.RedisAddr()) + redisClient := rediscache.New(cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisTLS) startupCtx, cancel := context.WithTimeout(context.Background(), cfg.HTTPReadTimeout) defer cancel() @@ -58,6 +59,8 @@ func New(cfg config.Config) (*App, error) { } tokenManager := token.NewManager(cfg.JWTSecret, cfg.JWTExpiresIn) + refreshStore := token.NewRefreshStore(redisClient.Raw(), cfg.RefreshTokenTTL) + tokenBlacklist := token.NewBlacklist(redisClient.Raw()) wizardService := wizard.NewService() @@ -70,9 +73,11 @@ func New(cfg config.Config) (*App, error) { recommendationEngine := recommendation.NewEngine(rules) recommendationService := recommendation.NewService(recommendationEngine) + secureCookie := strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") + userRepository := postgres.NewUserRepository(pg) - authService := auth.NewService(userRepository, tokenManager) - authHandler := auth.NewHandler(authService, tokenManager) + authService := auth.NewService(userRepository, tokenManager, refreshStore, tokenBlacklist) + authHandler := auth.NewHandler(authService, tokenManager, tokenBlacklist, cfg.RefreshTokenTTL, secureCookie) projectRepository := postgres.NewProjectRepository(pg) sessionRepository := rediscache.NewSessionRepository(redisClient, cfg.SessionTTL) @@ -89,6 +94,7 @@ func New(cfg config.Config) (*App, error) { _ = redisClient.Close() return nil, err } + projectService.WithPDFCleaner(store) m, err := bootstrap.NewMailer(cfg) if err != nil { @@ -125,6 +131,7 @@ func New(cfg config.Config) (*App, error) { Postgres: application.pg, Redis: application.redis, TokenManager: tokenManager, + TokenBlacklist: tokenBlacklist, AuthHandler: authHandler.Routes(), ProjectHandler: projectHandler, SessionHandler: sessionHandler, diff --git a/internal/auth/handler.go b/internal/auth/handler.go index a999c8e..504e580 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "net/http" + "strings" + "time" appmiddleware "github.com/gamidoc/backend/internal/http/middleware" "github.com/gamidoc/backend/internal/http/response" @@ -12,15 +14,23 @@ import ( "github.com/go-chi/chi/v5" ) +const refreshCookieName = "refresh_token" + type Handler struct { service *Service tokenManager *token.Manager + blacklist *token.Blacklist + refreshTTL time.Duration + secureCookie bool } -func NewHandler(service *Service, tokenManager *token.Manager) *Handler { +func NewHandler(service *Service, tokenManager *token.Manager, blacklist *token.Blacklist, refreshTTL time.Duration, secureCookie bool) *Handler { return &Handler{ service: service, tokenManager: tokenManager, + blacklist: blacklist, + refreshTTL: refreshTTL, + secureCookie: secureCookie, } } @@ -29,7 +39,9 @@ func (h *Handler) Routes() chi.Router { r.Post("/register", h.register) r.Post("/login", h.login) - r.With(appmiddleware.RequireAuth(h.tokenManager)).Get("/me", h.me) + r.Post("/refresh", h.refresh) + r.Post("/logout", h.logout) + r.With(appmiddleware.RequireAuth(h.tokenManager, h.blacklist)).Get("/me", h.me) return r } @@ -56,7 +68,11 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) { return } - response.WriteJSON(w, http.StatusCreated, result) + h.setRefreshCookie(w, result.RefreshToken) + response.WriteJSON(w, http.StatusCreated, map[string]any{ + "access_token": result.AccessToken, + "user": result.User, + }) } func (h *Handler) login(w http.ResponseWriter, r *http.Request) { @@ -77,7 +93,54 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) { return } - response.WriteJSON(w, http.StatusOK, result) + h.setRefreshCookie(w, result.RefreshToken) + response.WriteJSON(w, http.StatusOK, map[string]any{ + "access_token": result.AccessToken, + "user": result.User, + }) +} + +func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(refreshCookieName) + if err != nil || strings.TrimSpace(cookie.Value) == "" { + response.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Missing refresh token", nil) + return + } + + result, err := h.service.Refresh(r.Context(), cookie.Value) + if err != nil { + h.clearRefreshCookie(w) + response.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Invalid or expired refresh token", nil) + return + } + + h.setRefreshCookie(w, result.RefreshToken) + response.WriteJSON(w, http.StatusOK, map[string]any{ + "access_token": result.AccessToken, + "user": result.User, + }) +} + +func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { + // Extract access token from Authorization header + accessToken := "" + if header := r.Header.Get("Authorization"); header != "" { + parts := strings.SplitN(header, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + accessToken = parts[1] + } + } + + // Extract refresh token from cookie + refreshToken := "" + if cookie, err := r.Cookie(refreshCookieName); err == nil { + refreshToken = cookie.Value + } + + _ = h.service.Logout(r.Context(), accessToken, refreshToken) + + h.clearRefreshCookie(w) + response.WriteJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } func (h *Handler) me(w http.ResponseWriter, r *http.Request) { @@ -99,3 +162,27 @@ func (h *Handler) me(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, http.StatusOK, currentUser) } + +func (h *Handler) setRefreshCookie(w http.ResponseWriter, tokenValue string) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, + Value: tokenValue, + Path: "/api/v1/auth", + HttpOnly: true, + Secure: h.secureCookie, + SameSite: http.SameSiteStrictMode, + MaxAge: int(h.refreshTTL.Seconds()), + }) +} + +func (h *Handler) clearRefreshCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, + Value: "", + Path: "/api/v1/auth", + HttpOnly: true, + Secure: h.secureCookie, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 17c4935..274aa0f 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -18,8 +18,10 @@ var ErrEmailAlreadyExists = errors.New("email already exists") var ErrInvalidCredentials = errors.New("invalid credentials") type Service struct { - users user.Repository - tokens *token.Manager + users user.Repository + tokens *token.Manager + refreshStore *token.RefreshStore + blacklist *token.Blacklist } type RegisterInput struct { @@ -33,14 +35,17 @@ type LoginInput struct { } type AuthResult struct { - Token string `json:"token"` - User user.User `json:"user"` + AccessToken string `json:"access_token"` + User user.User `json:"user"` + RefreshToken string `json:"-"` // sent via httpOnly cookie, not in JSON body } -func NewService(users user.Repository, tokens *token.Manager) *Service { +func NewService(users user.Repository, tokens *token.Manager, refreshStore *token.RefreshStore, blacklist *token.Blacklist) *Service { return &Service{ - users: users, - tokens: tokens, + users: users, + tokens: tokens, + refreshStore: refreshStore, + blacklist: blacklist, } } @@ -75,15 +80,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (AuthResult return AuthResult{}, err } - tokenValue, err := s.tokens.Generate(createdUser.ID, createdUser.Email) - if err != nil { - return AuthResult{}, err - } - - return AuthResult{ - Token: tokenValue, - User: createdUser, - }, nil + return s.issueTokens(ctx, createdUser) } func (s *Service) Login(ctx context.Context, input LoginInput) (AuthResult, error) { @@ -100,17 +97,59 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (AuthResult, erro return AuthResult{}, ErrInvalidCredentials } - tokenValue, err := s.tokens.Generate(foundUser.ID, foundUser.Email) + return s.issueTokens(ctx, foundUser) +} + +func (s *Service) Refresh(ctx context.Context, refreshToken string) (AuthResult, error) { + userID, err := s.refreshStore.Validate(ctx, refreshToken) if err != nil { return AuthResult{}, err } - return AuthResult{ - Token: tokenValue, - User: foundUser, - }, nil + // Revoke old refresh token (rotation) + _ = s.refreshStore.Revoke(ctx, refreshToken) + + foundUser, err := s.users.FindByID(ctx, userID) + if err != nil { + return AuthResult{}, err + } + + return s.issueTokens(ctx, foundUser) +} + +func (s *Service) Logout(ctx context.Context, accessToken string, refreshToken string) error { + // Blacklist the current access token jti + claims, err := s.tokens.Parse(accessToken) + if err == nil && claims.ID != "" { + _ = s.blacklist.Add(ctx, claims.ID, claims.ExpiresAt.Time) + } + + // Revoke the refresh token + if refreshToken != "" { + _ = s.refreshStore.Revoke(ctx, refreshToken) + } + + return nil } func (s *Service) Me(ctx context.Context, userID string) (user.User, error) { return s.users.FindByID(ctx, userID) } + +func (s *Service) issueTokens(ctx context.Context, u user.User) (AuthResult, error) { + accessToken, err := s.tokens.Generate(u.ID) + if err != nil { + return AuthResult{}, err + } + + refreshToken, err := s.refreshStore.GenerateAndStore(ctx, u.ID) + if err != nil { + return AuthResult{}, err + } + + return AuthResult{ + AccessToken: accessToken, + User: u, + RefreshToken: refreshToken, + }, nil +} diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index 044f6b0..4127dd5 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -6,8 +6,10 @@ import ( "testing" "time" + "github.com/alicebob/miniredis/v2" "github.com/gamidoc/backend/internal/token" "github.com/gamidoc/backend/internal/user" + goredis "github.com/redis/go-redis/v9" ) type fakeUserRepository struct { @@ -48,13 +50,24 @@ func (r *fakeUserRepository) FindByID(ctx context.Context, id string) (user.User return u, nil } -func TestRegister(t *testing.T) { +func newTestService(t *testing.T) (*Service, *token.Manager) { + t.Helper() + mr := miniredis.RunT(t) + rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + repo := &fakeUserRepository{ usersByEmail: map[string]user.User{}, usersByID: map[string]user.User{}, } tokens := token.NewManager("secret", time.Hour) - service := NewService(repo, tokens) + refreshStore := token.NewRefreshStore(rdb, 7*24*time.Hour) + blacklist := token.NewBlacklist(rdb) + + return NewService(repo, tokens, refreshStore, blacklist), tokens +} + +func TestRegister(t *testing.T) { + service, _ := newTestService(t) result, err := service.Register(context.Background(), RegisterInput{ Email: "test@example.com", @@ -64,8 +77,12 @@ func TestRegister(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if result.Token == "" { - t.Fatal("expected token to be set") + if result.AccessToken == "" { + t.Fatal("expected access token to be set") + } + + if result.RefreshToken == "" { + t.Fatal("expected refresh token to be set") } if result.User.Email != "test@example.com" { @@ -74,12 +91,7 @@ func TestRegister(t *testing.T) { } func TestLogin(t *testing.T) { - repo := &fakeUserRepository{ - usersByEmail: map[string]user.User{}, - usersByID: map[string]user.User{}, - } - tokens := token.NewManager("secret", time.Hour) - service := NewService(repo, tokens) + service, _ := newTestService(t) registered, err := service.Register(context.Background(), RegisterInput{ Email: "test@example.com", @@ -97,11 +109,83 @@ func TestLogin(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if result.Token == "" { - t.Fatal("expected token to be set") + if result.AccessToken == "" { + t.Fatal("expected access token to be set") } if result.User.ID != registered.User.ID { t.Fatalf("expected user id %q, got %q", registered.User.ID, result.User.ID) } } + +func TestRefreshRotation(t *testing.T) { + service, _ := newTestService(t) + + registered, err := service.Register(context.Background(), RegisterInput{ + Email: "test@example.com", + Password: "password123", + }) + if err != nil { + t.Fatalf("register: %v", err) + } + + // Refresh using the original refresh token + refreshed, err := service.Refresh(context.Background(), registered.RefreshToken) + if err != nil { + t.Fatalf("refresh: %v", err) + } + + if refreshed.AccessToken == "" { + t.Fatal("expected new access token") + } + + // Old refresh token should be revoked (rotation) + _, err = service.Refresh(context.Background(), registered.RefreshToken) + if err == nil { + t.Fatal("expected error reusing old refresh token") + } + + // New refresh token should work + _, err = service.Refresh(context.Background(), refreshed.RefreshToken) + if err != nil { + t.Fatalf("expected new refresh token to work, got %v", err) + } +} + +func TestLogoutBlacklistsAccess(t *testing.T) { + service, tokens := newTestService(t) + + registered, err := service.Register(context.Background(), RegisterInput{ + Email: "test@example.com", + Password: "password123", + }) + if err != nil { + t.Fatalf("register: %v", err) + } + + // Logout should blacklist the access token's jti + err = service.Logout(context.Background(), registered.AccessToken, registered.RefreshToken) + if err != nil { + t.Fatalf("logout: %v", err) + } + + // Parse access token to get jti + claims, err := tokens.Parse(registered.AccessToken) + if err != nil { + t.Fatalf("parse: %v", err) + } + + revoked, err := service.blacklist.IsBlacklisted(context.Background(), claims.ID) + if err != nil { + t.Fatalf("blacklist check: %v", err) + } + if !revoked { + t.Fatal("expected jti to be blacklisted after logout") + } + + // Refresh token should also be revoked + _, err = service.Refresh(context.Background(), registered.RefreshToken) + if err == nil { + t.Fatal("expected refresh token to be revoked after logout") + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index d8b793d..5e353aa 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -60,7 +60,7 @@ func newDoctorCommand() *cobra.Command { return db.Ready(ctx) }) check("redis", func() error { - client := rediscache.New(cfg.RedisAddr()) + client := rediscache.New(cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisTLS) defer func() { _ = client.Close() }() diff --git a/internal/http/middleware/auth.go b/internal/http/middleware/auth.go index 08fddb3..5c40176 100644 --- a/internal/http/middleware/auth.go +++ b/internal/http/middleware/auth.go @@ -10,9 +10,13 @@ import ( ) type authUserIDKey struct{} -type authEmailKey struct{} -func RequireAuth(manager *token.Manager) func(http.Handler) http.Handler { +func RequireAuth(manager *token.Manager, blacklists ...*token.Blacklist) func(http.Handler) http.Handler { + var blacklist *token.Blacklist + if len(blacklists) > 0 { + blacklist = blacklists[0] + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if manager == nil { @@ -38,9 +42,16 @@ func RequireAuth(manager *token.Manager) func(http.Handler) http.Handler { return } - ctx := context.WithValue(r.Context(), authUserIDKey{}, claims.UserID) - ctx = context.WithValue(ctx, authEmailKey{}, claims.Email) + // Check jti blacklist + if blacklist != nil && claims.ID != "" { + revoked, err := blacklist.IsBlacklisted(r.Context(), claims.ID) + if err == nil && revoked { + response.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Token has been revoked", nil) + return + } + } + ctx := context.WithValue(r.Context(), authUserIDKey{}, claims.UserID) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -53,11 +64,3 @@ func GetAuthUserID(ctx context.Context) string { } return value } - -func GetAuthEmail(ctx context.Context) string { - value, ok := ctx.Value(authEmailKey{}).(string) - if !ok { - return "" - } - return value -} diff --git a/internal/http/middleware/cors.go b/internal/http/middleware/cors.go index f302493..467c452 100644 --- a/internal/http/middleware/cors.go +++ b/internal/http/middleware/cors.go @@ -14,6 +14,7 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler { if origin != "" && isOriginAllowed(origin, allowed) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") } diff --git a/internal/http/router.go b/internal/http/router.go index afbc9b1..f871979 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -29,6 +29,7 @@ type Dependencies struct { Postgres postgresReadyChecker Redis redisReadyChecker TokenManager *token.Manager + TokenBlacklist *token.Blacklist AuthHandler http.Handler ProjectHandler *project.Handler SessionHandler *session.Handler @@ -122,17 +123,20 @@ func NewRouter(deps Dependencies) http.Handler { } if deps.ProjectHandler != nil { - r.With(appmiddleware.RequireAuth(deps.TokenManager)).Mount("/projects", deps.ProjectHandler.Routes()) + r.With(appmiddleware.RequireAuth(deps.TokenManager, deps.TokenBlacklist)).Mount("/projects", deps.ProjectHandler.Routes()) } if deps.SessionHandler != nil { r.Mount("/sessions", deps.SessionHandler.Routes()) - r.With(appmiddleware.RequireAuth(deps.TokenManager)).Post("/sessions/{sessionId}/convert", deps.SessionHandler.Convert) + r.With(appmiddleware.RequireAuth(deps.TokenManager, deps.TokenBlacklist)).Post("/sessions/{sessionId}/convert", deps.SessionHandler.Convert) + r.With(appmiddleware.RequireAuth(deps.TokenManager, deps.TokenBlacklist)).Post("/sessions/{sessionId}/convert-to-project", deps.SessionHandler.Convert) } if deps.PDFHandler != nil { - r.With(appmiddleware.RequireAuth(deps.TokenManager)).Post("/projects/{projectId}/generate-pdf", deps.PDFHandler.ProjectGenerate) + r.With(appmiddleware.RequireAuth(deps.TokenManager, deps.TokenBlacklist)).Post("/projects/{projectId}/generate-pdf", deps.PDFHandler.ProjectGenerate) r.Post("/sessions/{sessionId}/generate-pdf", deps.PDFHandler.SessionGenerate) + r.With(appmiddleware.RequireAuth(deps.TokenManager, deps.TokenBlacklist)).Get("/projects/{projectId}/download-pdf", deps.PDFHandler.ProjectDownload) + r.Get("/sessions/{sessionId}/download-pdf", deps.PDFHandler.SessionDownload) } }) diff --git a/internal/http/router_test_helpers_test.go b/internal/http/router_test_helpers_test.go index dd2d6b7..6d47bce 100644 --- a/internal/http/router_test_helpers_test.go +++ b/internal/http/router_test_helpers_test.go @@ -8,6 +8,7 @@ import ( "os" "time" + "github.com/alicebob/miniredis/v2" "github.com/gamidoc/backend/internal/auth" "github.com/gamidoc/backend/internal/mailer" "github.com/gamidoc/backend/internal/pdf" @@ -18,6 +19,7 @@ import ( "github.com/gamidoc/backend/internal/token" "github.com/gamidoc/backend/internal/user" "github.com/gamidoc/backend/internal/wizard" + goredis "github.com/redis/go-redis/v9" ) type fakePostgres struct { @@ -269,7 +271,7 @@ func testTokenManager() *token.Manager { func authToken() string { manager := testTokenManager() - value, _ := manager.Generate("user-1", "test@example.com") + value, _ := manager.Generate("user-1") return value } @@ -279,13 +281,18 @@ func tTempDir() string { } func testAuthHandler() http.Handler { + mr, _ := miniredis.Run() + rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + repo := &fakeUserRepository{ usersByEmail: map[string]user.User{}, usersByID: map[string]user.User{}, } manager := testTokenManager() - service := auth.NewService(repo, manager) - handler := auth.NewHandler(service, manager) + refreshStore := token.NewRefreshStore(rdb, 7*24*time.Hour) + blacklist := token.NewBlacklist(rdb) + service := auth.NewService(repo, manager, refreshStore, blacklist) + handler := auth.NewHandler(service, manager, blacklist, 7*24*time.Hour, false) return handler.Routes() } diff --git a/internal/pdf/builder.go b/internal/pdf/builder.go index 56adcdb..7aab256 100644 --- a/internal/pdf/builder.go +++ b/internal/pdf/builder.go @@ -28,6 +28,13 @@ func (b *Builder) build(title string, createdAt time.Time, status wizard.Status, var projectType string var participants string var developmentStage string + var accessibility string + var timeConstraint string + var extraConstraints []string + var researchEnabled bool + var researchObjective string + var researchQuestions []string + var hypotheses []string var selectedMethods []MethodEntry var selectedInstruments []InstrumentEntry var nextSteps []string @@ -38,6 +45,13 @@ func (b *Builder) build(title string, createdAt time.Time, status wizard.Status, projectType = step1.ProjectType participants = step1.Participants developmentStage = step1.DevelopmentStage + accessibility = step1.Accessibility + timeConstraint = step1.Time + extraConstraints = step1.ExtraConstraints + researchEnabled = step1.ResearchEnabled + researchObjective = step1.ResearchObjective + researchQuestions = step1.ResearchQuestions + hypotheses = step1.Hypotheses } if step2, ok := wizard.DecodeStep2(status); ok { @@ -62,6 +76,13 @@ func (b *Builder) build(title string, createdAt time.Time, status wizard.Status, ProjectType: projectType, Participants: participants, DevelopmentStage: developmentStage, + Accessibility: accessibility, + TimeConstraint: timeConstraint, + ExtraConstraints: extraConstraints, + ResearchEnabled: researchEnabled, + ResearchObjective: researchObjective, + ResearchQuestions: researchQuestions, + Hypotheses: hypotheses, SelectedMethods: selectedMethods, SelectedInstruments: selectedInstruments, NextSteps: nextSteps, diff --git a/internal/pdf/domain.go b/internal/pdf/domain.go index 3444910..c77ebfa 100644 --- a/internal/pdf/domain.go +++ b/internal/pdf/domain.go @@ -32,6 +32,13 @@ type PlanData struct { ProjectType string Participants string DevelopmentStage string + Accessibility string + TimeConstraint string + ExtraConstraints []string + ResearchEnabled bool + ResearchObjective string + ResearchQuestions []string + Hypotheses []string SelectedMethods []MethodEntry SelectedInstruments []InstrumentEntry NextSteps []string diff --git a/internal/pdf/generator.go b/internal/pdf/generator.go index fd854eb..54a47b3 100644 --- a/internal/pdf/generator.go +++ b/internal/pdf/generator.go @@ -18,137 +18,315 @@ func NewFPDFGenerator() *FPDFGenerator { return &FPDFGenerator{} } +const ( + lMargin = 20.0 + rMargin = 20.0 + pageW = 210.0 + usableW = pageW - lMargin - rMargin // 170mm + cardIndent = 5.0 + cardW = usableW - cardIndent // 165mm +) + +// color helpers +func setFill(doc *fpdf.Fpdf, r, g, b int) { doc.SetFillColor(r, g, b) } +func setDraw(doc *fpdf.Fpdf, r, g, b int) { doc.SetDrawColor(r, g, b) } +func setText(doc *fpdf.Fpdf, r, g, b int) { doc.SetTextColor(r, g, b) } + func (g *FPDFGenerator) Generate(data PlanData) ([]byte, error) { doc := fpdf.New("P", "mm", "A4", "") + doc.SetMargins(lMargin, 20, rMargin) + doc.SetAutoPageBreak(true, 18) doc.SetTitle(data.Title, false) - doc.AddPage() - doc.SetFont("Arial", "B", 16) - doc.Cell(0, 10, data.Title) - doc.Ln(12) + doc.SetAuthor("GamiDoc", false) + doc.SetCreator("GamiDoc", false) - doc.SetFont("Arial", "", 11) - doc.Cell(0, 8, "Date: "+data.Date.Format("2006-01-02 15:04:05")) - doc.Ln(10) + doc.SetFooterFunc(func() { + doc.SetY(-14) + setDraw(doc, 180, 200, 220) + doc.Line(lMargin, doc.GetY(), pageW-rMargin, doc.GetY()) + doc.Ln(2) + doc.SetFont("Arial", "", 8) + setText(doc, 120, 120, 120) + doc.CellFormat(usableW/2, 5, "GamiDoc - Evaluation Plan", "", 0, "L", false, 0, "") + doc.CellFormat(usableW/2, 5, fmt.Sprintf("Page %d", doc.PageNo()), "", 0, "R", false, 0, "") + }) + doc.AddPage() + + writeTitleBlock(doc, data) writeContextSection(doc, data) + writeConstraintsSection(doc, data) + if data.ResearchEnabled { + writeResearchSection(doc, data) + } writeMethodsSection(doc, data.SelectedMethods) writeInstrumentsSection(doc, data.SelectedInstruments) writeSimpleSection(doc, "Next Steps", data.NextSteps) - if strings.TrimSpace(data.Notes) != "" { - doc.SetFont("Arial", "B", 13) - doc.Cell(0, 8, "Notes") - doc.Ln(9) - doc.SetFont("Arial", "", 11) - doc.MultiCell(0, 6, data.Notes, "", "L", false) - doc.Ln(2) + writeNotesSection(doc, data.Notes) } var buf bytes.Buffer if err := doc.Output(&buf); err != nil { return nil, err } - return buf.Bytes(), nil } -func writeContextSection(doc *fpdf.Fpdf, data PlanData) { - doc.SetFont("Arial", "B", 13) - doc.Cell(0, 8, "Evaluation Context") - doc.Ln(9) +func writeTitleBlock(doc *fpdf.Fpdf, data PlanData) { + // Navy banner — title + setFill(doc, 42, 87, 141) + setText(doc, 255, 255, 255) + doc.SetFont("Arial", "B", 20) + doc.CellFormat(0, 14, data.Title, "", 1, "C", true, 0, "") + + // Slightly lighter subtitle bar + setFill(doc, 60, 110, 170) + doc.SetFont("Arial", "", 10) + doc.CellFormat(0, 7, "Evaluation Plan - "+data.Date.Format("January 2, 2006"), "", 1, "C", true, 0, "") + setText(doc, 30, 30, 30) + doc.Ln(7) +} + +// sectionHeader draws a light-blue filled bar with a navy left+bottom border. +func sectionHeader(doc *fpdf.Fpdf, title string) { + setFill(doc, 235, 243, 252) + setDraw(doc, 42, 87, 141) + setText(doc, 42, 87, 141) + doc.SetFont("Arial", "B", 12) + doc.CellFormat(0, 9, " "+title, "LB", 1, "L", true, 0, "") + setText(doc, 30, 30, 30) + setDraw(doc, 200, 214, 229) + doc.Ln(3) +} + +// writeKV renders a small uppercase label then the value in normal text below it. +func writeKV(doc *fpdf.Fpdf, key, value string) { + doc.SetFont("Arial", "B", 8) + setText(doc, 110, 110, 110) + doc.CellFormat(0, 5, strings.ToUpper(key), "", 1, "L", false, 0, "") doc.SetFont("Arial", "", 11) - doc.MultiCell(0, 6, "Evaluation Goals: "+joinOrNone(data.EvaluationGoals), "", "L", false) - doc.MultiCell(0, 6, "Project Type: "+valueOrNone(data.ProjectType), "", "L", false) - doc.MultiCell(0, 6, "Participants: "+valueOrNone(data.Participants), "", "L", false) - doc.MultiCell(0, 6, "Development Stage: "+valueOrNone(data.DevelopmentStage), "", "L", false) + setText(doc, 30, 30, 30) + doc.MultiCell(0, 6, value, "", "L", false) doc.Ln(2) } -func writeMethodsSection(doc *fpdf.Fpdf, items []MethodEntry) { - doc.SetFont("Arial", "B", 13) - doc.Cell(0, 8, "Selected Methods") - doc.Ln(9) +func writeContextSection(doc *fpdf.Fpdf, data PlanData) { + sectionHeader(doc, "Evaluation Context") + writeKV(doc, "Evaluation Goals", joinOrNone(data.EvaluationGoals)) + writeKV(doc, "Project Type", valueOrNone(data.ProjectType)) + writeKV(doc, "Participants", valueOrNone(data.Participants)) + writeKV(doc, "Development Stage", valueOrNone(data.DevelopmentStage)) +} - doc.SetFont("Arial", "", 11) - if len(items) == 0 { - doc.MultiCell(0, 6, "- None", "", "L", false) - doc.Ln(2) +func writeConstraintsSection(doc *fpdf.Fpdf, data PlanData) { + hasAccessibility := strings.TrimSpace(data.Accessibility) != "" + hasTime := strings.TrimSpace(data.TimeConstraint) != "" + hasExtra := len(data.ExtraConstraints) > 0 + if !hasAccessibility && !hasTime && !hasExtra { return } - for _, item := range items { - lines := []string{fmt.Sprintf("- %s", strings.TrimSpace(item.Name))} - if strings.TrimSpace(item.Description) != "" { - lines = append(lines, " Description: "+item.Description) - } - if strings.TrimSpace(item.Priority) != "" { - lines = append(lines, " Priority: "+item.Priority) + sectionHeader(doc, "Constraints") + if hasAccessibility { + writeKV(doc, "User Accessibility", data.Accessibility) + } + if hasTime { + writeKV(doc, "Available Time", data.TimeConstraint) + } + if hasExtra { + writeKV(doc, "Additional Constraints", strings.Join(data.ExtraConstraints, "; ")) + } +} + +func writeResearchSection(doc *fpdf.Fpdf, data PlanData) { + sectionHeader(doc, "Research Specification") + + if strings.TrimSpace(data.ResearchObjective) != "" { + writeKV(doc, "Research Objective", data.ResearchObjective) + } + + if hasContent(data.ResearchQuestions) { + doc.SetFont("Arial", "B", 8) + setText(doc, 110, 110, 110) + doc.CellFormat(0, 5, "RESEARCH QUESTIONS", "", 1, "L", false, 0, "") + doc.Ln(1) + for i, rq := range data.ResearchQuestions { + if strings.TrimSpace(rq) != "" { + writeNumberedItem(doc, fmt.Sprintf("RQ%d", i+1), rq) + } } - if strings.TrimSpace(item.Rationale) != "" { - lines = append(lines, " Rationale: "+item.Rationale) + doc.Ln(2) + } + + if hasContent(data.Hypotheses) { + doc.SetFont("Arial", "B", 8) + setText(doc, 110, 110, 110) + doc.CellFormat(0, 5, "HYPOTHESES", "", 1, "L", false, 0, "") + doc.Ln(1) + for i, h := range data.Hypotheses { + if strings.TrimSpace(h) != "" { + writeNumberedItem(doc, fmt.Sprintf("H%d", i+1), h) + } } - doc.MultiCell(0, 6, strings.Join(lines, "\n"), "", "L", false) + doc.Ln(2) } - doc.Ln(2) } -func writeInstrumentsSection(doc *fpdf.Fpdf, items []InstrumentEntry) { - doc.SetFont("Arial", "B", 13) - doc.Cell(0, 8, "Recommended Instruments") - doc.Ln(9) +// writeNumberedItem renders "RQ1" / "H1" inline with the text. +// SetLeftMargin ensures wrapped lines align with the text start, not the label. +func writeNumberedItem(doc *fpdf.Fpdf, label, text string) { + const labelW = 13.0 - doc.SetFont("Arial", "", 11) + doc.SetFont("Arial", "B", 10) + setText(doc, 42, 87, 141) + doc.CellFormat(labelW, 6, label, "", 0, "L", false, 0, "") + + doc.SetLeftMargin(lMargin + labelW) + doc.SetFont("Arial", "", 10) + setText(doc, 30, 30, 30) + doc.MultiCell(usableW-labelW, 6, strings.TrimSpace(text), "", "L", false) + doc.SetLeftMargin(lMargin) + doc.SetX(lMargin) + doc.Ln(1) +} + +func writeMethodsSection(doc *fpdf.Fpdf, items []MethodEntry) { + sectionHeader(doc, "Selected Evaluation Methods") if len(items) == 0 { - doc.MultiCell(0, 6, "- None", "", "L", false) - doc.Ln(2) + writeEmpty(doc, "No methods selected.") return } + for _, item := range items { + writeEntryCard(doc, item.Name, item.Description, item.Priority, item.Rationale) + } +} +func writeInstrumentsSection(doc *fpdf.Fpdf, items []InstrumentEntry) { + sectionHeader(doc, "Recommended Instruments") + if len(items) == 0 { + writeEmpty(doc, "No instruments selected.") + return + } for _, item := range items { - lines := []string{fmt.Sprintf("- %s", strings.TrimSpace(item.Name))} - if strings.TrimSpace(item.Description) != "" { - lines = append(lines, " Description: "+item.Description) - } - if strings.TrimSpace(item.Priority) != "" { - lines = append(lines, " Priority: "+item.Priority) - } - if strings.TrimSpace(item.Rationale) != "" { - lines = append(lines, " Rationale: "+item.Rationale) - } - doc.MultiCell(0, 6, strings.Join(lines, "\n"), "", "L", false) + writeEntryCard(doc, item.Name, item.Description, item.Priority, item.Rationale) } - doc.Ln(2) } -func writeSimpleSection(doc *fpdf.Fpdf, title string, items []string) { - doc.SetFont("Arial", "B", 13) - doc.Cell(0, 8, title) - doc.Ln(9) +// writeEntryCard renders a method or instrument with a navy left accent bar. +// SetLeftMargin(lMargin+cardIndent) ensures MultiCell wrapping stays indented. +// The accent bar is skipped when content crosses a page break to avoid drawing +// a line that spans from the wrong Y coordinate on the new page. +func writeEntryCard(doc *fpdf.Fpdf, name, description, priority, rationale string) { + startPage := doc.PageNo() + startY := doc.GetY() + doc.SetLeftMargin(lMargin + cardIndent) - doc.SetFont("Arial", "", 11) + // Name + doc.SetFont("Arial", "B", 11) + setText(doc, 42, 87, 141) + doc.MultiCell(cardW, 7, strings.TrimSpace(name), "", "L", false) + + // Description + if strings.TrimSpace(description) != "" { + doc.SetFont("Arial", "", 10) + setText(doc, 30, 30, 30) + doc.MultiCell(cardW, 5, strings.TrimSpace(description), "", "L", false) + } + + // Priority | Rationale (italic, gray) + var meta []string + if strings.TrimSpace(priority) != "" { + meta = append(meta, "Priority: "+strings.TrimSpace(priority)) + } + if strings.TrimSpace(rationale) != "" { + meta = append(meta, "Rationale: "+strings.TrimSpace(rationale)) + } + if len(meta) > 0 { + doc.SetFont("Arial", "I", 9) + setText(doc, 110, 110, 110) + doc.MultiCell(cardW, 5, strings.Join(meta, " | "), "", "L", false) + } + + endY := doc.GetY() + endPage := doc.PageNo() + doc.SetLeftMargin(lMargin) + doc.SetX(lMargin) + + // Left accent bar: only draw when the card stays on a single page. + if startPage == endPage { + setDraw(doc, 42, 87, 141) + doc.Line(lMargin, startY, lMargin, endY) + } + + // Bottom separator (pale blue-gray) — always drawn on the current page. + setDraw(doc, 200, 214, 229) + doc.Line(lMargin+cardIndent, endY, lMargin+cardIndent+cardW, endY) + + setText(doc, 30, 30, 30) + doc.Ln(5) +} + +func writeSimpleSection(doc *fpdf.Fpdf, title string, items []string) { + sectionHeader(doc, title) if len(items) == 0 { - doc.MultiCell(0, 6, "- None", "", "L", false) - doc.Ln(2) + writeEmpty(doc, "None specified.") return } - + const bulletW = 6.0 for _, item := range items { - doc.MultiCell(0, 6, "- "+strings.TrimSpace(item), "", "L", false) + if strings.TrimSpace(item) == "" { + continue + } + doc.SetFont("Arial", "B", 11) + setText(doc, 42, 87, 141) + doc.CellFormat(bulletW, 6, "-", "", 0, "R", false, 0, "") + doc.SetLeftMargin(lMargin + bulletW) + doc.SetFont("Arial", "", 11) + setText(doc, 30, 30, 30) + doc.MultiCell(usableW-bulletW, 6, strings.TrimSpace(item), "", "L", false) + doc.SetLeftMargin(lMargin) + doc.SetX(lMargin) } doc.Ln(2) } +func writeNotesSection(doc *fpdf.Fpdf, notes string) { + sectionHeader(doc, "Notes") + doc.SetFont("Arial", "", 11) + setText(doc, 30, 30, 30) + doc.MultiCell(0, 6, strings.TrimSpace(notes), "", "L", false) + doc.Ln(2) +} + +func writeEmpty(doc *fpdf.Fpdf, msg string) { + doc.SetFont("Arial", "I", 10) + setText(doc, 130, 130, 130) + doc.CellFormat(0, 6, msg, "", 1, "L", false, 0, "") + setText(doc, 30, 30, 30) + doc.Ln(2) +} + +func hasContent(items []string) bool { + for _, s := range items { + if strings.TrimSpace(s) != "" { + return true + } + } + return false +} + func valueOrNone(value string) string { if strings.TrimSpace(value) == "" { - return "None" + return "-" } return value } func joinOrNone(items []string) string { if len(items) == 0 { - return "None" + return "-" } return strings.Join(items, ", ") } diff --git a/internal/pdf/handler.go b/internal/pdf/handler.go index c6d990b..0ac4327 100644 --- a/internal/pdf/handler.go +++ b/internal/pdf/handler.go @@ -103,6 +103,66 @@ func (h *Handler) SessionGenerate(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) ProjectDownload(w http.ResponseWriter, r *http.Request) { + userID := appmiddleware.GetAuthUserID(r.Context()) + if userID == "" { + response.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Unauthorized", nil) + return + } + + projectID := chi.URLParam(r, "projectId") + if projectID == "" { + response.WriteError(w, http.StatusBadRequest, "INVALID_PROJECT_ID", "Invalid project id", nil) + return + } + + data, err := h.service.DownloadProjectPDF(r.Context(), userID, projectID) + if err != nil { + switch { + case errors.Is(err, project.ErrProjectNotFound): + response.WriteError(w, http.StatusNotFound, "PROJECT_NOT_FOUND", "Project not found", nil) + case errors.Is(err, project.ErrForbiddenProject): + response.WriteError(w, http.StatusForbidden, "FORBIDDEN", "Project does not belong to user", nil) + case errors.Is(err, ErrPDFNotFound): + response.WriteError(w, http.StatusNotFound, "PDF_NOT_FOUND", "PDF not yet generated", nil) + default: + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + } + return + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="evaluation-plan.pdf"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (h *Handler) SessionDownload(w http.ResponseWriter, r *http.Request) { + sessionID := chi.URLParam(r, "sessionId") + if sessionID == "" { + response.WriteError(w, http.StatusBadRequest, "INVALID_SESSION_ID", "Invalid session id", nil) + return + } + + data, err := h.service.DownloadSessionPDF(r.Context(), sessionID) + if err != nil { + switch { + case errors.Is(err, session.ErrSessionNotFound): + response.WriteError(w, http.StatusNotFound, "SESSION_NOT_FOUND", "Session not found or expired", nil) + case errors.Is(err, ErrPDFNotFound): + response.WriteError(w, http.StatusNotFound, "PDF_NOT_FOUND", "PDF not yet generated", nil) + default: + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + } + return + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="evaluation-plan.pdf"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { key := chi.URLParam(r, "*") key = strings.TrimLeft(key, "/") diff --git a/internal/pdf/service.go b/internal/pdf/service.go index 1e09f55..604a325 100644 --- a/internal/pdf/service.go +++ b/internal/pdf/service.go @@ -17,6 +17,7 @@ import ( ) var ErrInvalidNotifyEmail = fmt.Errorf("invalid notify email") +var ErrPDFNotFound = fmt.Errorf("pdf not found") type ProjectRepository interface { FindByID(ctx context.Context, id string) (project.Project, error) @@ -199,6 +200,39 @@ func (s *Service) Download(ctx context.Context, key string) ([]byte, error) { return s.store.Read(ctx, key) } +func (s *Service) DownloadProjectPDF(ctx context.Context, userID, projectID string) ([]byte, error) { + item, err := s.projects.FindByID(ctx, projectID) + if err != nil { + return nil, err + } + if item.UserID != userID { + return nil, project.ErrForbiddenProject + } + if item.PDFURL == nil { + return nil, ErrPDFNotFound + } + key, ok := s.store.KeyFromURL(*item.PDFURL) + if !ok { + return nil, ErrPDFNotFound + } + return s.store.Read(ctx, key) +} + +func (s *Service) DownloadSessionPDF(ctx context.Context, sessionID string) ([]byte, error) { + item, err := s.sessions.FindByID(ctx, sessionID) + if err != nil { + return nil, err + } + if item.PDFURL == nil { + return nil, ErrPDFNotFound + } + key, ok := s.store.KeyFromURL(*item.PDFURL) + if !ok { + return nil, ErrPDFNotFound + } + return s.store.Read(ctx, key) +} + func (s *Service) sendPDFReadyEmail(ctx context.Context, notifyEmail string, title string, pdfURL string) *EmailDelivery { notifyEmail = strings.TrimSpace(notifyEmail) if notifyEmail == "" || s.mailer == nil { diff --git a/internal/pdf/service_test.go b/internal/pdf/service_test.go index 28c0699..00c96c2 100644 --- a/internal/pdf/service_test.go +++ b/internal/pdf/service_test.go @@ -34,6 +34,19 @@ func (s *fakeObjectStore) Read(ctx context.Context, key string) ([]byte, error) return value, nil } +func (s *fakeObjectStore) Delete(ctx context.Context, key string) error { + delete(s.items, key) + return nil +} + +func (s *fakeObjectStore) KeyFromURL(url string) (string, bool) { + const prefix = "/files/pdfs/" + if len(url) <= len(prefix) || url[:len(prefix)] != prefix { + return "", false + } + return url[len(prefix):], true +} + type fakeMailer struct { sendErr error result mailer.SendResult diff --git a/internal/project/service.go b/internal/project/service.go index 4d58af4..bf018ff 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -19,11 +19,18 @@ type SessionWizardReader interface { FindWizardByID(ctx context.Context, sessionID string) (wizard.Status, error) } +// PDFCleaner allows deleting a stored PDF file by URL. +type PDFCleaner interface { + KeyFromURL(url string) (string, bool) + Delete(ctx context.Context, key string) error +} + type Service struct { projects Repository sessions SessionWizardReader wizard *wizard.Service recommendations *recommendation.Service + store PDFCleaner } type CreateInput struct { @@ -32,8 +39,8 @@ type CreateInput struct { } type UpdateInput struct { - Name string `json:"name"` - Description string `json:"description"` + Name *string `json:"name"` + Description *string `json:"description"` } type ConvertInput struct { @@ -54,6 +61,11 @@ func NewService(projects Repository, sessions SessionWizardReader, wizardService } } +func (s *Service) WithPDFCleaner(store PDFCleaner) *Service { + s.store = store + return s +} + func (s *Service) Create(ctx context.Context, userID string, input CreateInput) (Project, error) { name := strings.TrimSpace(input.Name) description := strings.TrimSpace(input.Description) @@ -151,11 +163,17 @@ func (s *Service) Update(ctx context.Context, userID string, projectID string, i return Project{}, ErrForbiddenProject } - name := strings.TrimSpace(input.Name) - description := strings.TrimSpace(input.Description) + name := found.Name + if input.Name != nil { + name = strings.TrimSpace(*input.Name) + if name == "" { + return Project{}, ErrInvalidProjectName + } + } - if name == "" { - return Project{}, ErrInvalidProjectName + description := found.Description + if input.Description != nil { + description = strings.TrimSpace(*input.Description) } return s.projects.UpdateInfo(ctx, projectID, name, description) @@ -171,5 +189,11 @@ func (s *Service) Delete(ctx context.Context, userID string, projectID string) e return ErrForbiddenProject } + if s.store != nil && found.PDFURL != nil { + if key, ok := s.store.KeyFromURL(*found.PDFURL); ok { + _ = s.store.Delete(ctx, key) + } + } + return s.projects.Delete(ctx, projectID) } diff --git a/internal/recommendation/engine.go b/internal/recommendation/engine.go index 7cd97e5..e4ff565 100644 --- a/internal/recommendation/engine.go +++ b/internal/recommendation/engine.go @@ -41,6 +41,22 @@ func (e *Engine) Recommend(input Input) []Recommendation { continue } + if !matchesAny(input.Accessibility, rule.RequiredAccessibility) { + continue + } + + if !matchesAny(input.Time, rule.RequiredTime) { + continue + } + + if !matchesAnyInSlice(input.ExtraConstraints, rule.RequiredExtraConstraints) { + continue + } + + if rule.RequiredResearchEnabled != nil && *rule.RequiredResearchEnabled != input.ResearchEnabled { + continue + } + for _, rec := range rule.Recommendations { if seen[rec.ID] { continue @@ -93,6 +109,24 @@ func matchesAny(have string, required []string) bool { return false } +// matchesAnyInSlice returns true if at least one element of required +// is present in have, or if required is empty (no constraint). +func matchesAnyInSlice(have []string, required []string) bool { + if len(required) == 0 { + return true + } + set := map[string]bool{} + for _, item := range have { + set[item] = true + } + for _, item := range required { + if set[item] { + return true + } + } + return false +} + func priorityRank(priority string) int { switch priority { case "Recommended": diff --git a/internal/recommendation/rule.go b/internal/recommendation/rule.go index d3a592e..0254e98 100644 --- a/internal/recommendation/rule.go +++ b/internal/recommendation/rule.go @@ -7,5 +7,9 @@ type Rule struct { RequiredParticipants []string `json:"requiredParticipants"` RequiredDevelopmentStages []string `json:"requiredDevelopmentStages"` RequiredMethods []string `json:"requiredMethods"` + RequiredAccessibility []string `json:"requiredAccessibility"` + RequiredTime []string `json:"requiredTime"` + RequiredExtraConstraints []string `json:"requiredExtraConstraints"` + RequiredResearchEnabled *bool `json:"requiredResearchEnabled"` Recommendations []Recommendation `json:"recommendations"` } diff --git a/internal/recommendation/service.go b/internal/recommendation/service.go index bde8a9c..63e5662 100644 --- a/internal/recommendation/service.go +++ b/internal/recommendation/service.go @@ -2,12 +2,35 @@ package recommendation import ( "errors" + "strings" "github.com/gamidoc/backend/internal/wizard" ) var ErrInvalidRecommendationStep = errors.New("invalid recommendation step") +// methodNameToID maps the display names stored by the frontend to the rule IDs +// used in recommendations.json. Any name not found here is normalised by +// lower-casing and replacing spaces with hyphens. +var methodNameToID = map[string]string{ + "Think-aloud testing": "think-aloud", + "Surveys & Questionnaires": "surveys", + "Heuristic evaluation": "heuristic-evaluation", + "Expert review": "expert-review", + "Interview": "interview", + "Observation": "observation", + "Focus Group": "focus-group", + "Diary Study": "diary-study", + "Experience Report": "experience-report", +} + +func normalizeMethodName(name string) string { + if id, ok := methodNameToID[name]; ok { + return id + } + return strings.ToLower(strings.ReplaceAll(name, " ", "-")) +} + type Service struct { engine *Engine } @@ -19,6 +42,10 @@ type Input struct { Participants string DevelopmentStage string SelectedMethods []string + Accessibility string + Time string + ExtraConstraints []string + ResearchEnabled bool } func NewService(engine *Engine) *Service { @@ -41,10 +68,18 @@ func (s *Service) Recommend(status wizard.Status, forStep int) (Result, error) { input.ProjectType = step1.ProjectType input.Participants = step1.Participants input.DevelopmentStage = step1.DevelopmentStage + input.Accessibility = step1.Accessibility + input.Time = step1.Time + input.ExtraConstraints = step1.ExtraConstraints + input.ResearchEnabled = step1.ResearchEnabled } if step2, ok := wizard.DecodeStep2(status); ok { - input.SelectedMethods = step2.SelectedMethods + normalized := make([]string, len(step2.SelectedMethods)) + for i, name := range step2.SelectedMethods { + normalized[i] = normalizeMethodName(name) + } + input.SelectedMethods = normalized } return Result{ diff --git a/internal/storage/objectstore/local_store.go b/internal/storage/objectstore/local_store.go index c589743..aefeab3 100644 --- a/internal/storage/objectstore/local_store.go +++ b/internal/storage/objectstore/local_store.go @@ -36,3 +36,21 @@ func (s *LocalStore) Read(ctx context.Context, key string) ([]byte, error) { path := filepath.Join(s.rootDir, filepath.FromSlash(trimLeftSlash(key))) return os.ReadFile(path) } + +func (s *LocalStore) Delete(ctx context.Context, key string) error { + path := filepath.Join(s.rootDir, filepath.FromSlash(trimLeftSlash(key))) + err := os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err +} + +func (s *LocalStore) KeyFromURL(url string) (string, bool) { + base := trimRightSlash(s.baseURL) + prefix := base + "/" + if len(url) <= len(prefix) || url[:len(prefix)] != prefix { + return "", false + } + return url[len(prefix):], true +} diff --git a/internal/storage/objectstore/s3_store.go b/internal/storage/objectstore/s3_store.go index 17f1f69..f68d5cb 100644 --- a/internal/storage/objectstore/s3_store.go +++ b/internal/storage/objectstore/s3_store.go @@ -92,6 +92,26 @@ func (s *S3Store) Read(ctx context.Context, key string) ([]byte, error) { return io.ReadAll(output.Body) } +func (s *S3Store) Delete(ctx context.Context, key string) error { + if ctx == nil { + ctx = context.Background() + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &s.bucket, + Key: stringPtr(trimLeftSlash(key)), + }) + return err +} + +func (s *S3Store) KeyFromURL(url string) (string, bool) { + base := trimRightSlash(s.baseURL) + prefix := base + "/" + if len(url) <= len(prefix) || url[:len(prefix)] != prefix { + return "", false + } + return url[len(prefix):], true +} + func stringPtr(value string) *string { return &value } diff --git a/internal/storage/objectstore/store.go b/internal/storage/objectstore/store.go index 59c6d39..3da989d 100644 --- a/internal/storage/objectstore/store.go +++ b/internal/storage/objectstore/store.go @@ -5,6 +5,8 @@ import "context" type ObjectStore interface { Save(ctx context.Context, key string, data []byte) (string, error) Read(ctx context.Context, key string) ([]byte, error) + Delete(ctx context.Context, key string) error + KeyFromURL(url string) (string, bool) } func buildPublicURL(baseURL string, key string) string { diff --git a/internal/storage/redis/client.go b/internal/storage/redis/client.go index 1f74142..4eaac7a 100644 --- a/internal/storage/redis/client.go +++ b/internal/storage/redis/client.go @@ -2,6 +2,7 @@ package redis import ( "context" + "crypto/tls" goredis "github.com/redis/go-redis/v9" ) @@ -10,11 +11,15 @@ type Client struct { redis *goredis.Client } -func New(addr string) *Client { - client := goredis.NewClient(&goredis.Options{ - Addr: addr, - }) - return &Client{redis: client} +func New(addr, password string, useTLS bool) *Client { + opts := &goredis.Options{ + Addr: addr, + Password: password, + } + if useTLS { + opts.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + return &Client{redis: goredis.NewClient(opts)} } func (c *Client) Ping(ctx context.Context) error { diff --git a/internal/token/blacklist.go b/internal/token/blacklist.go new file mode 100644 index 0000000..e430c9b --- /dev/null +++ b/internal/token/blacklist.go @@ -0,0 +1,42 @@ +package token + +import ( + "context" + "errors" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +type Blacklist struct { + redis *goredis.Client +} + +func NewBlacklist(redis *goredis.Client) *Blacklist { + return &Blacklist{redis: redis} +} + +// Add blacklists a JWT ID until its natural expiry time. +func (bl *Blacklist) Add(ctx context.Context, jti string, expiresAt time.Time) error { + ttl := time.Until(expiresAt) + if ttl <= 0 { + return nil // already expired, nothing to blacklist + } + return bl.redis.Set(ctx, blacklistKey(jti), "1", ttl).Err() +} + +// IsBlacklisted returns true if the jti has been revoked. +func (bl *Blacklist) IsBlacklisted(ctx context.Context, jti string) (bool, error) { + _, err := bl.redis.Get(ctx, blacklistKey(jti)).Result() + if err != nil { + if errors.Is(err, goredis.Nil) { + return false, nil + } + return false, err + } + return true, nil +} + +func blacklistKey(jti string) string { + return "blacklist:" + jti +} diff --git a/internal/token/claims.go b/internal/token/claims.go index 8275165..6ab3e90 100644 --- a/internal/token/claims.go +++ b/internal/token/claims.go @@ -4,6 +4,5 @@ import "github.com/golang-jwt/jwt/v5" type Claims struct { UserID string `json:"user_id"` - Email string `json:"email"` jwt.RegisteredClaims } diff --git a/internal/token/jwt.go b/internal/token/jwt.go index 0b2aac4..4c24729 100644 --- a/internal/token/jwt.go +++ b/internal/token/jwt.go @@ -5,6 +5,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) var ErrInvalidToken = errors.New("invalid token") @@ -21,13 +22,17 @@ func NewManager(secret string, expiresIn time.Duration) *Manager { } } -func (m *Manager) Generate(userID string, email string) (string, error) { +func (m *Manager) ExpiresIn() time.Duration { + return m.expiresIn +} + +func (m *Manager) Generate(userID string) (string, error) { now := time.Now() claims := Claims{ UserID: userID, - Email: email, RegisteredClaims: jwt.RegisteredClaims{ + ID: uuid.NewString(), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(m.expiresIn)), }, diff --git a/internal/token/jwt_test.go b/internal/token/jwt_test.go index 0558220..e7ee61b 100644 --- a/internal/token/jwt_test.go +++ b/internal/token/jwt_test.go @@ -9,7 +9,7 @@ import ( func TestManagerGenerateAndParse(t *testing.T) { manager := NewManager("secret", time.Hour) - value, err := manager.Generate("user-1", "test@example.com") + value, err := manager.Generate("user-1") if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -23,8 +23,8 @@ func TestManagerGenerateAndParse(t *testing.T) { t.Fatalf("expected user id %q, got %q", "user-1", claims.UserID) } - if claims.Email != "test@example.com" { - t.Fatalf("expected email %q, got %q", "test@example.com", claims.Email) + if claims.ID == "" { + t.Fatal("expected jti to be set") } } @@ -40,7 +40,7 @@ func TestManagerRejectsInvalidToken(t *testing.T) { func TestManagerRejectsExpiredToken(t *testing.T) { manager := NewManager("secret", -time.Hour) - value, err := manager.Generate("user-1", "test@example.com") + value, err := manager.Generate("user-1") if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -55,7 +55,7 @@ func TestManagerRejectsWrongSecret(t *testing.T) { manager1 := NewManager("secret-1", time.Hour) manager2 := NewManager("secret-2", time.Hour) - value, err := manager1.Generate("user-1", "test@example.com") + value, err := manager1.Generate("user-1") if err != nil { t.Fatalf("expected no error, got %v", err) } diff --git a/internal/token/refresh.go b/internal/token/refresh.go new file mode 100644 index 0000000..b1714e1 --- /dev/null +++ b/internal/token/refresh.go @@ -0,0 +1,69 @@ +package token + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +var ( + ErrRefreshTokenNotFound = errors.New("refresh token not found") + ErrRefreshTokenExpired = errors.New("refresh token expired") +) + +type RefreshStore struct { + redis *goredis.Client + ttl time.Duration +} + +func NewRefreshStore(redis *goredis.Client, ttl time.Duration) *RefreshStore { + return &RefreshStore{redis: redis, ttl: ttl} +} + +func (s *RefreshStore) TTL() time.Duration { + return s.ttl +} + +// GenerateAndStore creates a new opaque refresh token, stores it in Redis keyed +// to the userID, and returns the token string. +func (s *RefreshStore) GenerateAndStore(ctx context.Context, userID string) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + tokenStr := hex.EncodeToString(b) + + key := refreshKey(tokenStr) + if err := s.redis.Set(ctx, key, userID, s.ttl).Err(); err != nil { + return "", err + } + + return tokenStr, nil +} + +// Validate looks up the refresh token and returns the associated userID. +func (s *RefreshStore) Validate(ctx context.Context, tokenStr string) (string, error) { + key := refreshKey(tokenStr) + userID, err := s.redis.Get(ctx, key).Result() + if err != nil { + if errors.Is(err, goredis.Nil) { + return "", ErrRefreshTokenNotFound + } + return "", err + } + return userID, nil +} + +// Revoke deletes a refresh token from Redis. +func (s *RefreshStore) Revoke(ctx context.Context, tokenStr string) error { + key := refreshKey(tokenStr) + return s.redis.Del(ctx, key).Err() +} + +func refreshKey(token string) string { + return "refresh:" + token +} diff --git a/internal/wizard/steps.go b/internal/wizard/steps.go index fefd957..d6be2de 100644 --- a/internal/wizard/steps.go +++ b/internal/wizard/steps.go @@ -14,6 +14,17 @@ type Step1Data struct { ProjectType string `json:"projectType"` Participants string `json:"participants"` DevelopmentStage string `json:"developmentStage"` + + // Constraints (optional) + Accessibility string `json:"accessibility,omitempty"` + Time string `json:"time,omitempty"` + ExtraConstraints []string `json:"extraConstraints,omitempty"` + + // Research specification (optional) + ResearchEnabled bool `json:"researchEnabled,omitempty"` + ResearchObjective string `json:"researchObjective,omitempty"` + ResearchQuestions []string `json:"researchQuestions,omitempty"` + Hypotheses []string `json:"hypotheses,omitempty"` } type Step2Data struct { diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..04f53b9 --- /dev/null +++ b/render.yaml @@ -0,0 +1,9 @@ +services: + - type: web + name: gamidoc-backend + runtime: go + buildCommand: go build -o app ./cmd/gamidoc-backend/ + startCommand: ./app serve + envVars: + - key: APP_ENV + value: production diff --git a/rule/recommendations.json b/rule/recommendations.json index 83e51f8..f69173d 100644 --- a/rule/recommendations.json +++ b/rule/recommendations.json @@ -1,7 +1,7 @@ [ { "forStep": 2, - "requiredEvaluationGoals": ["Usability & Playability"], + "requiredEvaluationGoals": ["Usability & Efficiency"], "requiredProjectTypes": [], "requiredParticipants": [], "requiredDevelopmentStages": [], @@ -10,16 +10,16 @@ { "id": "think-aloud", "name": "Think-aloud testing", - "description": "Users verbalize thoughts while interacting with the system", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", "priority": "Recommended", - "rationale": "Useful for early usability evaluation with limited participants" + "rationale": "Direct observation of user interactions is ideal for identifying efficiency and usability problems." }, { "id": "surveys", "name": "Surveys & Questionnaires", - "description": "Collect structured user feedback through questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", "priority": "Recommended", - "rationale": "Helpful for measuring perceived usability and satisfaction" + "rationale": "Validated usability scales complement observational data with measurable user perceptions." } ] }, @@ -34,57 +34,1101 @@ { "id": "heuristic-evaluation", "name": "Heuristic evaluation", - "description": "Experts inspect the interface against established usability principles", + "description": "Experts inspect the interface against established usability principles to identify design problems.", "priority": "Recommended", - "rationale": "Useful for identifying guidance and feedback issues early" + "rationale": "Heuristic evaluation efficiently uncovers guidance and feedback issues without requiring user recruitment." }, { "id": "surveys", "name": "Surveys & Questionnaires", - "description": "Collect structured user feedback through questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", "priority": "Engagement", - "rationale": "Can complement expert-based findings with user perceptions" + "rationale": "Questionnaires can complement expert findings with user-perceived feedback quality measures." } ] }, { "forStep": 2, - "requiredEvaluationGoals": ["Usability & Playability"], - "requiredProjectTypes": ["Concept test"], - "requiredParticipants": ["Limited set of participants"], - "requiredDevelopmentStages": ["Concept idea"], + "requiredEvaluationGoals": ["Usability & Efficiency"], + "requiredProjectTypes": [], + "requiredParticipants": ["< 10"], + "requiredDevelopmentStages": ["Concept / idea"], + "requiredMethods": [], + "recommendations": [ + { + "id": "expert-review", + "name": "Expert review", + "description": "A focused expert inspection of the proposed experience and interaction flow at concept level.", + "priority": "Recommended", + "rationale": "When participants are scarce and the system is at concept stage, expert review is the most viable evaluation approach." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Novelty / Curiosity"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Think-aloud sessions reveal spontaneous curiosity and novelty reactions that users express during exploration." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "Hedonic questionnaires (e.g. AttrakDiff, UEQ) quantify perceived novelty and stimulation." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Aesthetic & Attractiveness"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "Validated aesthetic scales (e.g. AttrakDiff, VisAWI) are the primary tool for measuring visual appeal." + }, + { + "id": "expert-review", + "name": "Expert review", + "description": "A focused expert inspection of the proposed experience and interaction flow at concept level.", + "priority": "Engagement", + "rationale": "Design experts can assess aesthetic coherence and visual quality without user involvement." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Affective Experience"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Think-aloud captures emotional reactions and mood shifts as they happen during interaction." + }, + { + "id": "interview", + "name": "Interview", + "description": "A structured or semi-structured conversation with users to explore their experiences, emotions, and opinions in depth.", + "priority": "Recommended", + "rationale": "Interviews allow deep exploration of users' affective states and emotional responses to the system." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "Affect scales (e.g. SAM, PANAS) provide quantifiable measurements of emotional states." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Tension / Pressure"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], "requiredMethods": [], "recommendations": [ { - "id": "expert-review", - "name": "Expert review", - "description": "A focused expert inspection of the proposed experience and interaction flow", + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Observing users in real time reveals stress behaviors and verbalized pressure during challenging tasks." + }, + { + "id": "observation", + "name": "Observation", + "description": "Watching users interact with the system in context, without intervention, to capture natural behaviors and stress indicators.", + "priority": "Recommended", + "rationale": "Direct observation captures physiological and behavioral cues of tension that self-report methods may miss." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "Workload and tension scales (e.g. NASA-TLX, GEQ tension subscale) quantify subjective perceived pressure." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Competence / Mastery"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "Need-satisfaction scales (e.g. PENS, IMI) directly measure users' sense of competence and mastery." + }, + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Engagement", + "rationale": "Think-aloud reveals moments where users feel competent or struggle with the system's challenge level." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Autonomy / Perceived Choice"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "PENS, IMI, and BNS scales measure users' perceived autonomy and sense of meaningful choice." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Social Connection / Relatedness"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "PENS relatedness subscale and social presence scales measure sense of connection within the system." + }, + { + "id": "focus-group", + "name": "Focus Group", + "description": "A moderated group discussion (typically 6–10 users) to explore shared perceptions, social dynamics, and community experiences.", + "priority": "Recommended", + "rationale": "Group discussions naturally reveal social dynamics, belonging, and collective experience of the gamified system." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Motivation Spectrum"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "IMI and SMS scales measure the full intrinsic-to-extrinsic motivation spectrum directly." + }, + { + "id": "interview", + "name": "Interview", + "description": "A structured or semi-structured conversation with users to explore their experiences, emotions, and opinions in depth.", + "priority": "Engagement", + "rationale": "Interviews allow exploration of personal motivational drivers that standardized scales may not capture." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Behavioral Impact"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "observation", + "name": "Observation", + "description": "Watching users interact with the system in context, without intervention, to capture natural behaviors and stress indicators.", + "priority": "Recommended", + "rationale": "Direct observation captures actual behavioral changes that self-reported methods cannot reliably measure." + }, + { + "id": "diary-study", + "name": "Diary Study", + "description": "Participants self-report their experiences, behaviors, and motivations over an extended period to capture longitudinal patterns.", + "priority": "Recommended", + "rationale": "Diary studies track behavior change over time, revealing whether gamification produces lasting behavioral impact." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "BREQ and Goal Attainment scales measure self-reported behavioral regulation and goal achievement." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Progress / Accomplishment"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Think-aloud reveals how users perceive progress indicators and respond to achievement mechanics." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "Flow and cognitive engagement scales (e.g. FSS-2, EGameFlow) measure the quality of the progress experience." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Engagement"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Think-aloud captures engagement depth by revealing the degree of user involvement and interest in real time." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "The User Engagement Scale (UES) and GEQ engrossment subscale measure multi-dimensional engagement." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Immersion / Flow / Focused Attention"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Concurrent think-aloud can surface immersion breakpoints — moments where flow is disrupted." + }, + { + "id": "diary-study", + "name": "Diary Study", + "description": "Participants self-report their experiences, behaviors, and motivations over an extended period to capture longitudinal patterns.", + "priority": "Engagement", + "rationale": "Diary studies capture flow episodes and immersion patterns across repeated sessions over time." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Personal Meaning & Relevance"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "interview", + "name": "Interview", + "description": "A structured or semi-structured conversation with users to explore their experiences, emotions, and opinions in depth.", + "priority": "Recommended", + "rationale": "Interviews are uniquely suited to exploring how users connect the system's content to their personal values and goals." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "miniPXI and MEEGA+ scales measure perceived personal relevance and meaningful experience." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Purpose & Values Alignment"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "interview", + "name": "Interview", + "description": "A structured or semi-structured conversation with users to explore their experiences, emotions, and opinions in depth.", + "priority": "Recommended", + "rationale": "In-depth interviews reveal whether the system's purpose aligns with users' deeper motivations and personal values." + }, + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Engagement", + "rationale": "miniPXI purpose subscale measures how well the experience aligns with users' personal values." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": ["Self-Transcendence"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "interview", + "name": "Interview", + "description": "A structured or semi-structured conversation with users to explore their experiences, emotions, and opinions in depth.", + "priority": "Recommended", + "rationale": "Interviews capture reflective narratives about transcendent experiences and connection to something larger than self." + }, + { + "id": "focus-group", + "name": "Focus Group", + "description": "A moderated group discussion (typically 6–10 users) to explore shared perceptions, social dynamics, and community experiences.", + "priority": "Engagement", + "rationale": "Group discussions reveal shared experiences of meaning and collective transcendence within the community." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": ["100+"], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "At large scale, surveys are the only feasible method to reach and collect data from 100+ participants." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": ["< 10"], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "recommendations": [ + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Think-aloud provides rich qualitative insights from even a small number of participants." + }, + { + "id": "expert-review", + "name": "Expert review", + "description": "A focused expert inspection of the proposed experience and interaction flow at concept level.", + "priority": "Engagement", + "rationale": "Expert review compensates for limited user availability by leveraging specialist knowledge." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "requiredTime": ["1 week"], + "recommendations": [ + { + "id": "expert-review", + "name": "Expert review", + "description": "A focused expert inspection of the proposed experience and interaction flow at concept level.", + "priority": "Recommended", + "rationale": "Expert review can be completed within a week without participant recruitment or session scheduling." + }, + { + "id": "heuristic-evaluation", + "name": "Heuristic evaluation", + "description": "Experts inspect the interface against established usability principles to identify design problems.", + "priority": "Engagement", + "rationale": "Heuristic evaluation is fast to conduct and yields actionable results within tight time constraints." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "requiredAccessibility": ["Difficult"], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "Remote questionnaires can reach users with difficult accessibility without requiring in-person sessions." + }, + { + "id": "heuristic-evaluation", + "name": "Heuristic evaluation", + "description": "Experts inspect the interface against established usability principles to identify design problems.", + "priority": "Engagement", + "rationale": "When direct user access is limited, expert evaluation provides a valuable accessibility-independent alternative." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "requiredExtraConstraints": ["Sensitive population"], + "recommendations": [ + { + "id": "surveys", + "name": "Surveys & Questionnaires", + "description": "Collect structured user feedback through standardized questionnaires measuring attitudes, satisfaction, and usability.", + "priority": "Recommended", + "rationale": "Anonymous self-report questionnaires reduce risk for participants from sensitive populations." + }, + { + "id": "interview", + "name": "Interview", + "description": "A structured or semi-structured conversation with users to explore their experiences, emotions, and opinions in depth.", + "priority": "Engagement", + "rationale": "Interviews with sensitive populations require careful ethics protocols but provide richer, safer data collection." + } + ] + }, + { + "forStep": 2, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": [], + "requiredResearchEnabled": true, + "recommendations": [ + { + "id": "think-aloud", + "name": "Think-aloud testing", + "description": "Users verbalize thoughts while interacting with the system to surface usability issues in real time.", + "priority": "Recommended", + "rationale": "Think-aloud generates rich qualitative data aligned with research questions and hypothesis testing." + }, + { + "id": "interview", + "name": "Interview", + "description": "A structured or semi-structured conversation with users to explore their experiences, emotions, and opinions in depth.", + "priority": "Recommended", + "rationale": "Structured interviews allow direct operationalisation of research questions as interview items." + } + ] + }, + + { + "forStep": 3, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "umux-lite", + "name": "UMUX-Lite", + "description": "Measure perceived usability with 2 items — a fast and validated scale suited to any context.", + "priority": "Recommended", + "rationale": "A lightweight baseline instrument applicable to any survey-based evaluation." + }, + { + "id": "sus", + "name": "SUS", + "description": "10-item System Usability Scale — the most widely used standardized usability questionnaire.", + "priority": "Engagement", + "rationale": "SUS provides a comprehensive usability benchmark with established normative data." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["think-aloud"], + "recommendations": [ + { + "id": "observation-grid", + "name": "Observation Grid", + "description": "Structured note-taking sheet for recording observed usability issues and user behaviours.", + "priority": "Recommended", + "rationale": "Provides systematic structure for capturing qualitative data during think-aloud sessions." + }, + { + "id": "think-aloud-script", + "name": "Think-Aloud script", + "description": "A facilitator script instructing users how to think aloud and guiding session flow.", + "priority": "Recommended", + "rationale": "Ensures consistent facilitation and maximises the quality of think-aloud verbalizations." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": ["Low-fidelity prototype", "High-fidelity prototype"], + "requiredMethods": ["think-aloud"], + "recommendations": [ + { + "id": "task-based-protocol", + "name": "Task based protocol", + "description": "A structured task scenario guide that leads users through specific interactions to identify usability issues.", + "priority": "Recommended", + "rationale": "Task-based protocols are most effective when the prototype is interactive enough to support task completion." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["heuristic-evaluation"], + "recommendations": [ + { + "id": "heuristic-checklist", + "name": "Heuristic Checklist", + "description": "A checklist derived from recognized usability and game UX heuristics for systematic expert inspection.", + "priority": "Recommended", + "rationale": "Provides a structured framework to ensure consistent coverage of all heuristic principles." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["expert-review"], + "recommendations": [ + { + "id": "heuristic-checklist", + "name": "Heuristic Checklist", + "description": "A checklist derived from recognized usability and game UX heuristics for systematic expert inspection.", + "priority": "Recommended", + "rationale": "A heuristic checklist structures the expert review and ensures no dimension is overlooked." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Usability & Efficiency"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "attrakdiff", + "name": "AttrakDiff", + "description": "Semantic differential scale measuring pragmatic qualities, hedonic qualities, and overall attractiveness.", + "priority": "Recommended", + "rationale": "AttrakDiff captures both pragmatic (usability) and hedonic dimensions, providing a complete UX profile." + }, + { + "id": "ueq", + "name": "UEQ", + "description": "User Experience Questionnaire — measures attractiveness, perspicuity, efficiency, dependability, stimulation, and novelty.", + "priority": "Engagement", + "rationale": "UEQ's efficiency subscale directly targets the usability dimension with established norms." + }, + { + "id": "mecue", + "name": "meCUE", + "description": "Modular Evaluation of Key Components of User Experience — covers usefulness, usability, visual aesthetics, and emotions.", + "priority": "Engagement", + "rationale": "meCUE's usefulness and usability modules align precisely with efficiency evaluation goals." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Guidance & Feedback"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "sus", + "name": "SUS", + "description": "10-item System Usability Scale — the most widely used standardized usability questionnaire.", + "priority": "Recommended", + "rationale": "SUS learnability and usability items capture how effectively the system guides users." + }, + { + "id": "mecue", + "name": "meCUE", + "description": "Modular Evaluation of Key Components of User Experience — covers usefulness, usability, visual aesthetics, and emotions.", + "priority": "Engagement", + "rationale": "meCUE's feedback and control modules directly measure perceived guidance quality." + }, + { + "id": "umux-lite", + "name": "UMUX-Lite", + "description": "Measure perceived usability with 2 items — a fast and validated scale suited to any context.", "priority": "Engagement", - "rationale": "Useful when the project is still at concept level and direct user testing is limited" + "rationale": "UMUX-Lite is a fast complement to assess overall perceived usability in guidance contexts." } ] }, { "forStep": 3, - "requiredEvaluationGoals": [], + "requiredEvaluationGoals": ["Novelty / Curiosity"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "attrakdiff", + "name": "AttrakDiff", + "description": "Semantic differential scale measuring pragmatic qualities, hedonic qualities, and overall attractiveness.", + "priority": "Recommended", + "rationale": "AttrakDiff's hedonic-stimulation subscale directly measures perceived novelty and stimulation." + }, + { + "id": "ueq", + "name": "UEQ", + "description": "User Experience Questionnaire — measures attractiveness, perspicuity, efficiency, dependability, stimulation, and novelty.", + "priority": "Engagement", + "rationale": "UEQ includes dedicated novelty and stimulation subscales aligned with curiosity goals." + }, + { + "id": "geq", + "name": "GEQ", + "description": "Game Experience Questionnaire — measures immersion, flow, competence, tension, challenge, and positive/negative affect in game contexts.", + "priority": "Engagement", + "rationale": "GEQ's curiosity and immersion subscales capture engagement with novel game mechanics." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Aesthetic & Attractiveness"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "attrakdiff", + "name": "AttrakDiff", + "description": "Semantic differential scale measuring pragmatic qualities, hedonic qualities, and overall attractiveness.", + "priority": "Recommended", + "rationale": "AttrakDiff's attractiveness subscale is the benchmark tool for measuring perceived visual appeal." + }, + { + "id": "ueq", + "name": "UEQ", + "description": "User Experience Questionnaire — measures attractiveness, perspicuity, efficiency, dependability, stimulation, and novelty.", + "priority": "Engagement", + "rationale": "UEQ's attractiveness subscale complements AttrakDiff with a different validated perspective." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Affective Experience"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "sam", + "name": "SAM", + "description": "Self-Assessment Manikin — a non-verbal pictorial scale measuring valence, arousal, and dominance.", + "priority": "Recommended", + "rationale": "SAM captures immediate emotional states quickly and cross-culturally, ideal for affective experience measurement." + }, + { + "id": "panas", + "name": "PANAS", + "description": "Positive and Negative Affect Schedule — measures the intensity of positive and negative emotions.", + "priority": "Engagement", + "rationale": "PANAS provides a structured measure of both positive and negative affective states across interaction." + }, + { + "id": "geq", + "name": "GEQ", + "description": "Game Experience Questionnaire — measures immersion, flow, competence, tension, challenge, and positive/negative affect in game contexts.", + "priority": "Engagement", + "rationale": "GEQ's affect subscales are specifically validated for gamified and game-like contexts." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Tension / Pressure"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "geq", + "name": "GEQ", + "description": "Game Experience Questionnaire — measures immersion, flow, competence, tension, challenge, and positive/negative affect in game contexts.", + "priority": "Recommended", + "rationale": "GEQ's tension and annoyance subscales are specifically designed for measuring stress in game contexts." + }, + { + "id": "nasa-tlx", + "name": "NASA-TLX", + "description": "NASA Task Load Index — a multidimensional workload scale measuring mental demand, physical demand, and frustration.", + "priority": "Engagement", + "rationale": "NASA-TLX quantifies cognitive and perceived workload, directly measuring tension arising from task difficulty." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Competence / Mastery"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "pens", + "name": "PENS", + "description": "Player Experience of Need Satisfaction — measures competence, autonomy, and relatedness need satisfaction in game/play contexts.", + "priority": "Recommended", + "rationale": "PENS competence subscale directly measures the sense of mastery and effectiveness within the system." + }, + { + "id": "imi", + "name": "IMI", + "description": "Intrinsic Motivation Inventory — measures interest/enjoyment, perceived competence, autonomy, relatedness, and effort.", + "priority": "Engagement", + "rationale": "IMI perceived competence subscale captures the intrinsic satisfaction derived from skill development." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Autonomy / Perceived Choice"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "pens", + "name": "PENS", + "description": "Player Experience of Need Satisfaction — measures competence, autonomy, and relatedness need satisfaction in game/play contexts.", + "priority": "Recommended", + "rationale": "PENS autonomy subscale measures users' sense of meaningful choice and self-direction within the system." + }, + { + "id": "imi", + "name": "IMI", + "description": "Intrinsic Motivation Inventory — measures interest/enjoyment, perceived competence, autonomy, relatedness, and effort.", + "priority": "Engagement", + "rationale": "IMI perceived choice subscale captures the degree to which users feel their actions are self-determined." + }, + { + "id": "bns", + "name": "BNS", + "description": "Basic Need Satisfaction Scale — measures the degree to which autonomy, competence, and relatedness needs are met.", + "priority": "Engagement", + "rationale": "BNS autonomy subscale provides a theory-grounded measure of perceived choice and volitional engagement." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Social Connection / Relatedness"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "pens", + "name": "PENS", + "description": "Player Experience of Need Satisfaction — measures competence, autonomy, and relatedness need satisfaction in game/play contexts.", + "priority": "Recommended", + "rationale": "PENS relatedness subscale measures users' sense of social connection and belonging within the system." + }, + { + "id": "geq", + "name": "GEQ", + "description": "Game Experience Questionnaire — measures immersion, flow, competence, tension, challenge, and positive/negative affect in game contexts.", + "priority": "Engagement", + "rationale": "GEQ social presence subscale captures felt social connectedness in multiplayer or community contexts." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Motivation Spectrum"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "imi", + "name": "IMI", + "description": "Intrinsic Motivation Inventory — measures interest/enjoyment, perceived competence, autonomy, relatedness, and effort.", + "priority": "Recommended", + "rationale": "IMI covers the full spectrum from intrinsic interest/enjoyment to identified and extrinsic regulation." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Behavioral Impact"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "imi", + "name": "IMI", + "description": "Intrinsic Motivation Inventory — measures interest/enjoyment, perceived competence, autonomy, relatedness, and effort.", + "priority": "Recommended", + "rationale": "IMI effort and importance subscales measure the behavioural investment users make in the system." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Progress / Accomplishment"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "fss2", + "name": "FSS-2", + "description": "Flow State Scale — measures the nine dimensions of flow as described by Csikszentmihalyi.", + "priority": "Recommended", + "rationale": "FSS-2 concentration and goal clarity subscales capture the cognitive quality of the progress experience." + }, + { + "id": "egameflow", + "name": "EGameFlow", + "description": "E-Learning Game Flow Scale — measures flow in educational game contexts across concentration, goal clarity, and feedback dimensions.", + "priority": "Engagement", + "rationale": "EGameFlow's goal clarity and concentration subscales directly reflect the quality of progress mechanics." + }, + { + "id": "geq", + "name": "GEQ", + "description": "Game Experience Questionnaire — measures immersion, flow, competence, tension, challenge, and positive/negative affect in game contexts.", + "priority": "Engagement", + "rationale": "GEQ cognitive immersion subscale captures the mental absorption associated with progress and accomplishment." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Engagement"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "ues", + "name": "UES", + "description": "User Engagement Scale — measures focused attention, felt involvement, aesthetic appeal, and reward dimensions of engagement.", + "priority": "Recommended", + "rationale": "UES is the most comprehensive validated instrument for measuring multi-dimensional user engagement." + }, + { + "id": "geq", + "name": "GEQ", + "description": "Game Experience Questionnaire — measures immersion, flow, competence, tension, challenge, and positive/negative affect in game contexts.", + "priority": "Engagement", + "rationale": "GEQ engrossment subscale measures depth of engagement specifically in gamified contexts." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Immersion / Flow / Focused Attention"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "fss2", + "name": "FSS-2", + "description": "Flow State Scale — measures the nine dimensions of flow as described by Csikszentmihalyi.", + "priority": "Recommended", + "rationale": "FSS-2 is the gold standard for measuring flow state, covering absorption, time distortion, and focused attention." + }, + { + "id": "egameflow", + "name": "EGameFlow", + "description": "E-Learning Game Flow Scale — measures flow in educational game contexts across concentration, goal clarity, and feedback dimensions.", + "priority": "Engagement", + "rationale": "EGameFlow extends flow measurement specifically to game-based learning contexts." + }, + { + "id": "geq", + "name": "GEQ", + "description": "Game Experience Questionnaire — measures immersion, flow, competence, tension, challenge, and positive/negative affect in game contexts.", + "priority": "Engagement", + "rationale": "GEQ immersion subscale captures the experiential depth of focused attention in game contexts." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Personal Meaning & Relevance"], "requiredProjectTypes": [], "requiredParticipants": [], "requiredDevelopmentStages": [], "requiredMethods": ["surveys"], "recommendations": [ { - "id": "useq-like", - "name": "USEQ-Like", - "description": "A short questionnaire targeting usability experience", + "id": "minipxi", + "name": "miniPXI", + "description": "Mini Player Experience Inventory — a brief scale measuring meaning, purpose, and transcendence in game experiences.", "priority": "Recommended", - "rationale": "Suitable when using questionnaires for usability evaluation" + "rationale": "miniPXI meaning subscale directly measures the degree to which the experience feels personally significant." }, + { + "id": "meega", + "name": "MEEGA+", + "description": "Model for Evaluation of Educational Games — measures usability, fun, and learning experience in educational game contexts.", + "priority": "Engagement", + "rationale": "MEEGA+ relevance subscale captures perceived alignment between the game content and users' learning goals." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Purpose & Values Alignment"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "minipxi", + "name": "miniPXI", + "description": "Mini Player Experience Inventory — a brief scale measuring meaning, purpose, and transcendence in game experiences.", + "priority": "Recommended", + "rationale": "miniPXI purpose subscale measures how well the experience aligns with users' personal values and goals." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": ["Self-Transcendence"], + "requiredProjectTypes": [], + "requiredParticipants": [], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ + { + "id": "minipxi", + "name": "miniPXI", + "description": "Mini Player Experience Inventory — a brief scale measuring meaning, purpose, and transcendence in game experiences.", + "priority": "Recommended", + "rationale": "miniPXI transcendence subscale measures experiences of connection to something greater than oneself." + } + ] + }, + { + "forStep": 3, + "requiredEvaluationGoals": [], + "requiredProjectTypes": [], + "requiredParticipants": ["100+"], + "requiredDevelopmentStages": [], + "requiredMethods": ["surveys"], + "recommendations": [ { "id": "sus", "name": "SUS", - "description": "System Usability Scale", + "description": "10-item System Usability Scale — the most widely used standardized usability questionnaire.", + "priority": "Recommended", + "rationale": "SUS provides statistically robust results at scale, with extensive normative benchmarking data available." + }, + { + "id": "ueq", + "name": "UEQ", + "description": "User Experience Questionnaire — measures attractiveness, perspicuity, efficiency, dependability, stimulation, and novelty.", "priority": "Engagement", - "rationale": "Widely used instrument for perceived usability" + "rationale": "UEQ delivers a comprehensive multi-dimensional UX profile well-suited to large-sample studies." } ] }, @@ -92,16 +1136,16 @@ "forStep": 3, "requiredEvaluationGoals": [], "requiredProjectTypes": [], - "requiredParticipants": [], + "requiredParticipants": ["< 10"], "requiredDevelopmentStages": [], - "requiredMethods": ["think-aloud"], + "requiredMethods": ["surveys"], "recommendations": [ { - "id": "observation-grid", - "name": "Observation Grid", - "description": "Structured note-taking sheet for observed usability issues", + "id": "umux-lite", + "name": "UMUX-Lite", + "description": "Measure perceived usability with 2 items — a fast and validated scale suited to any context.", "priority": "Recommended", - "rationale": "Useful for capturing qualitative insights during think-aloud sessions" + "rationale": "UMUX-Lite's minimal response burden makes it ideal when participants are few and session time is limited." } ] }, @@ -111,14 +1155,15 @@ "requiredProjectTypes": [], "requiredParticipants": [], "requiredDevelopmentStages": [], - "requiredMethods": ["heuristic-evaluation"], + "requiredMethods": ["surveys"], + "requiredTime": ["1 week"], "recommendations": [ { - "id": "heuristic-checklist", - "name": "Heuristic Checklist", - "description": "A checklist derived from recognized usability and game UX heuristics", + "id": "umux-lite", + "name": "UMUX-Lite", + "description": "Measure perceived usability with 2 items — a fast and validated scale suited to any context.", "priority": "Recommended", - "rationale": "Supports systematic expert inspection and documentation of issues" + "rationale": "UMUX-Lite can be deployed and analysed within a single day, fitting tight one-week evaluation windows." } ] }