diff --git a/backend/Dockerfile b/backend/Dockerfile index 0fc0808..bfa5b4e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24 AS build +FROM golang:1.25 AS build ENV CGO_ENABLED=1 diff --git a/backend/api/handler/admin.go b/backend/api/handler/admin.go new file mode 100644 index 0000000..8bf0dd9 --- /dev/null +++ b/backend/api/handler/admin.go @@ -0,0 +1,236 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Rastaiha/bermudia/internal/config" + "github.com/Rastaiha/bermudia/internal/domain" + "github.com/Rastaiha/bermudia/internal/service" + "github.com/go-chi/chi/v5" + "log/slog" + "net/http" + "strings" +) + +type admin struct { + cfg config.Config + adminService *service.Admin +} + +type adminLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (a *admin) Login(w http.ResponseWriter, r *http.Request) { + var req adminLoginRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + sendDecodeError(w) + return + } + token, err := a.adminService.Login(r.Context(), req.Username, req.Password) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + sendError(w, http.StatusNotFound, "نام کاربری یا کلمه عبور اشتباه است") + return + } + a.handleAdminError(w, err) + return + } + sendResult(w, map[string]any{ + "token": token, + }) +} + +func (a *admin) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if tokenStr == "" { + sendError(w, http.StatusUnauthorized, "Missing auth token") + return + } + ok := a.adminService.ValidateToken(r.Context(), tokenStr) + if !ok { + sendError(w, http.StatusUnauthorized, "Invalid auth token") + return + } + + next.ServeHTTP(w, r) + }) +} + +func (a *admin) GetTerritories(w http.ResponseWriter, r *http.Request) { + result, err := a.adminService.GetTerritories(r.Context()) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) SetTerritory(w http.ResponseWriter, r *http.Request) { + var territory domain.Territory + if err := json.NewDecoder(r.Body).Decode(&territory); err != nil { + sendDecodeError(w) + return + } + if err := a.adminService.SetTerritory(r.Context(), territory); err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, territory) +} + +func (a *admin) GetBook(w http.ResponseWriter, r *http.Request) { + bookID := chi.URLParam(r, "bookID") + result, err := a.adminService.GetBook(r.Context(), bookID) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) GetIslandHeader(w http.ResponseWriter, r *http.Request) { + islandID := chi.URLParam(r, "islandID") + result, err := a.adminService.GetIslandHeader(r.Context(), islandID) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) SetBookAndBindToIsland(w http.ResponseWriter, r *http.Request) { + islandID := chi.URLParam(r, "islandID") + var input service.BookInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + sendDecodeError(w) + return + } + result, err := a.adminService.SetBookAndBindToIsland(r.Context(), islandID, input) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) GetPools(w http.ResponseWriter, r *http.Request) { + result, err := a.adminService.GetPools(r.Context()) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) SetBookAndBindToPool(w http.ResponseWriter, r *http.Request) { + poolID := chi.URLParam(r, "poolID") + var input service.BookInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + sendDecodeError(w) + return + } + result, err := a.adminService.SetBookAndBindToPool(r.Context(), poolID, input) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) GetTerritoryIslandBindings(w http.ResponseWriter, r *http.Request) { + territoryID := chi.URLParam(r, "territoryID") + result, err := a.adminService.GetTerritoryIslandBindings(r.Context(), territoryID) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) SetTerritoryIslandBindings(w http.ResponseWriter, r *http.Request) { + var bindings service.TerritoryIslandBindings + if err := json.NewDecoder(r.Body).Decode(&bindings); err != nil { + sendDecodeError(w) + return + } + result, err := a.adminService.SetTerritoryIslandBindings(r.Context(), bindings) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) CreateUser(w http.ResponseWriter, r *http.Request) { + var user service.User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + sendDecodeError(w) + return + } + result, err := a.adminService.CreateUser(r.Context(), user) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} + +func (a *admin) handleAdminError(w http.ResponseWriter, err error) { + if errors.Is(err, context.Canceled) { + sendError(w, http.StatusRequestTimeout, "Request cancelled") + return + } + var adminError service.AdminError + if errors.As(err, &adminError) { + sendError(w, http.StatusBadRequest, adminError.Error()) + return + } + var domainError domain.Error + if errors.As(err, &domainError) { + switch domainError.Reason() { + case domain.ErrorReasonResourceNotFound: + sendError(w, http.StatusNotFound, domainError.Error()) + case domain.ErrorReasonRuleViolation: + sendError(w, http.StatusConflict, domainError.Error()) + default: + sendError(w, http.StatusInternalServerError, domainError.Error()) + } + return + } + switch { + case errors.Is(err, domain.ErrTerritoryNotFound): + sendError(w, http.StatusNotFound, err.Error()) + return + case errors.Is(err, domain.ErrIslandNotFound): + sendError(w, http.StatusNotFound, err.Error()) + return + case errors.Is(err, domain.ErrBookNotFound): + sendError(w, http.StatusNotFound, err.Error()) + return + case errors.Is(err, domain.ErrPoolSettingsNotFound): + sendError(w, http.StatusNotFound, err.Error()) + return + } + + errText := "" + if err != nil { + errText = err.Error() + } + slog.Error("internal admin error", slog.String("error", errText)) + sendError(w, http.StatusInternalServerError, fmt.Sprintf("Internal server error: %s", errText)) +} + +func (a *admin) GetUsers(w http.ResponseWriter, r *http.Request) { + result, err := a.adminService.GetUsers(r.Context()) + if err != nil { + a.handleAdminError(w, err) + return + } + sendResult(w, result) +} diff --git a/backend/api/handler/handler.go b/backend/api/handler/handler.go index 34ba252..80aca3f 100644 --- a/backend/api/handler/handler.go +++ b/backend/api/handler/handler.go @@ -19,6 +19,7 @@ import ( type Handler struct { cfg config.Config + adminHandler *admin server *http.Server wsUpgrader websocket.Upgrader authService *service.Auth @@ -30,9 +31,13 @@ type Handler struct { inboxHub *hub.Hub } -func New(cfg config.Config, authService *service.Auth, territoryService *service.Territory, islandService *service.Island, playerService *service.Player) *Handler { +func New(cfg config.Config, authService *service.Auth, adminService *service.Admin, territoryService *service.Territory, islandService *service.Island, playerService *service.Player) *Handler { return &Handler{ - cfg: cfg, + cfg: cfg, + adminHandler: &admin{ + cfg: cfg, + adminService: adminService, + }, authService: authService, territoryService: territoryService, islandService: islandService, @@ -105,6 +110,25 @@ func (h *Handler) Start() { }) }) + r.Route("/admin", func(r chi.Router) { + r.Post("/login", h.adminHandler.Login) + + r.Group(func(r chi.Router) { + r.Use(h.adminHandler.authMiddleware) + r.Get("/territories", h.adminHandler.GetTerritories) + r.Post("/territories", h.adminHandler.SetTerritory) + r.Get("/territories/{territoryID}/island_bindings", h.adminHandler.GetTerritoryIslandBindings) + r.Post("/territories/{territoryID}/island_bindings", h.adminHandler.SetTerritoryIslandBindings) + r.Get("/books/{bookID}", h.adminHandler.GetBook) + r.Get("/islands/{islandID}", h.adminHandler.GetIslandHeader) + r.Post("/islands/{islandID}/book", h.adminHandler.SetBookAndBindToIsland) + r.Get("/pools", h.adminHandler.GetPools) + r.Post("/pools/{poolID}/books", h.adminHandler.SetBookAndBindToPool) + r.Get("/users", h.adminHandler.GetUsers) + r.Post("/users", h.adminHandler.CreateUser) + }) + }) + // Health check r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/backend/go.mod b/backend/go.mod index 428d71e..ee27071 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Rastaiha/bermudia -go 1.24.3 +go 1.25 require ( github.com/go-chi/chi/v5 v5.2.2 @@ -10,7 +10,6 @@ require ( require ( github.com/go-co-op/gocron/v2 v2.16.3 github.com/go-telegram/bot v1.17.0 - github.com/go-viper/mapstructure/v2 v2.3.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.7.5 @@ -18,10 +17,12 @@ require ( github.com/knadh/koanf/providers/structs v1.0.0 github.com/knadh/koanf/v2 v2.2.2 github.com/mattn/go-sqlite3 v1.14.32 + github.com/patrickmn/go-cache v2.1.0+incompatible ) require ( github.com/fatih/structs v1.1.0 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -30,7 +31,6 @@ require ( github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/robfig/cron/v3 v3.0.1 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/text v0.28.0 // indirect diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 374c4b0..6ebe685 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -20,6 +20,8 @@ type Config struct { CorrectionRevertWindow time.Duration `config:"correction_revert_window"` CreateMock bool `config:"create_mock"` AdminsGroup int64 `config:"admins_group"` + AdminUsername string `config:"admin_username"` + AdminPassword string `config:"admin_password"` } func (c Config) TokenSigningKeyBytes() []byte { diff --git a/backend/internal/domain/island.go b/backend/internal/domain/island.go index 3204411..5016200 100644 --- a/backend/internal/domain/island.go +++ b/backend/internal/domain/island.go @@ -59,12 +59,15 @@ type Island struct { type Book struct { ID string `json:"id"` Components []BookComponent `json:"components"` - Treasures []Treasure `json:"treasures"` } type BookComponent struct { - IFrame *IslandIFrame `json:"iframe,omitempty"` - Question *Question `json:"question,omitempty"` + IFrame *IslandIFrame `json:"iframe,omitempty"` + Question *QuestionPlaceholder `json:"question,omitempty"` +} + +type QuestionPlaceholder struct { + ID string `json:"id"` } type IslandContent struct { @@ -126,6 +129,10 @@ func IsPoolIdValid(poolId string) bool { return poolId == PoolEasy || poolId == PoolMedium || poolId == PoolHard } +func PoolIds() []string { + return []string{PoolEasy, PoolMedium, PoolHard} +} + type TerritoryPoolSettings struct { Easy int32 `json:"easy"` Medium int32 `json:"medium"` diff --git a/backend/internal/domain/question.go b/backend/internal/domain/question.go index fa90a1d..c593fc7 100644 --- a/backend/internal/domain/question.go +++ b/backend/internal/domain/question.go @@ -5,17 +5,12 @@ import ( "time" ) -type Question struct { - ID string `json:"id"` - Text string `json:"text"` - InputType string `json:"inputType"` - InputAccept []string `json:"inputAccept"` -} - type BookQuestion struct { QuestionID string BookID string Text string + InputType string + InputAccept []string KnowledgeAmount int32 RewardSource string Context string diff --git a/backend/internal/domain/store.go b/backend/internal/domain/store.go index 487fbb8..ccdf46f 100644 --- a/backend/internal/domain/store.go +++ b/backend/internal/domain/store.go @@ -15,9 +15,11 @@ var ( ErrAnswerNotPending = errors.New("answer not in pending state") ErrQuestionNotRelatedToIsland = errors.New("question not related to island") ErrInvalidIslandHeader = errors.New("invalid island header") + ErrPoolSettingsNotFound = errors.New("pool settings not found") ErrPoolSettingExhausted = errors.New("pool setting exhausted") ErrBookPoolExhausted = errors.New("book pool exhausted") ErrNoBookAssignedFromPool = errors.New("no book assigned from pool") + ErrBookNotFound = errors.New("book not found") ErrEmptyIsland = errors.New("empty island") ErrUserTreasureConflict = errors.New("user treasure update conflict") ErrAlreadyApplied = errors.New("already applied") @@ -49,6 +51,7 @@ type IslandStore interface { SetTerritoryPoolSettings(ctx context.Context, territoryId string, settings TerritoryPoolSettings) error GetTerritoryPoolSettings(ctx context.Context, territoryId string) (TerritoryPoolSettings, error) AddBookToPool(ctx context.Context, poolId string, bookId string) error + GetBooksInPool(ctx context.Context, poolId string) (bookIds []string, err error) GetPoolOfBook(ctx context.Context, bookId string) (poolId string, found bool, err error) AssignBookToIslandFromPool(ctx context.Context, territoryId string, islandId string, userId int32) (bookId string, err error) IsIslandPortable(ctx context.Context, userId int32, islandId string) (bool, error) @@ -60,6 +63,7 @@ type UserStore interface { Create(ctx context.Context, user *User) error Get(ctx context.Context, id int32) (*User, error) GetByUsername(ctx context.Context, username string) (*User, error) + GetAll(ctx context.Context) (users []User, err error) } type PlayerStore interface { @@ -87,6 +91,7 @@ type QuestionStore interface { GetKnowledgeBars(ctx context.Context, userId int32) ([]KnowledgeBar, error) HasAnsweredIsland(ctx context.Context, userId int32, islandId string) (bool, error) GetQuestion(ctx context.Context, questionId string) (BookQuestion, error) + GetQuestions(ctx context.Context, bookId string) ([]BookQuestion, error) CreateCorrection(ctx context.Context, Correction Correction) error ApplyCorrection(ctx context.Context, tx Tx, ifBefore time.Time, correction Correction) (Answer, bool, error) GetUnappliedCorrections(ctx context.Context, before time.Time) ([]Correction, error) @@ -99,6 +104,7 @@ type TreasureStore interface { BindTreasuresToBook(ctx context.Context, bookId string, treasures []Treasure) error GetOrCreateUserTreasure(ctx context.Context, userId int32, treasureId string) (UserTreasure, error) GetTreasure(ctx context.Context, treasureId string) (Treasure, error) + GetTreasures(ctx context.Context, bookId string) ([]Treasure, error) GetUserTreasure(ctx context.Context, userId int32, treasureId string) (UserTreasure, error) UpdateUserTreasure(ctx context.Context, old UserTreasure, updated UserTreasure) error } diff --git a/backend/internal/mock/mock.go b/backend/internal/mock/mock.go index 0027f96..0142e52 100644 --- a/backend/internal/mock/mock.go +++ b/backend/internal/mock/mock.go @@ -88,11 +88,11 @@ func createMockUsers(adminService *service.Admin, files fs.FS, writeBack string, ctx := context.Background() var errs []error result := make([]service.User, 0, len(users)) - for i, u := range users { + for _, u := range users { if u.Password == "" && defaultPass != "" { u.Password = defaultPass } - u, err := adminService.CreateUser(ctx, i, u) + u, err := adminService.CreateUser(ctx, u) errs = append(errs, err) result = append(result, u) } diff --git a/backend/internal/repository/island.go b/backend/internal/repository/island.go index aa5ca19..ffc4eb8 100644 --- a/backend/internal/repository/island.go +++ b/backend/internal/repository/island.go @@ -121,6 +121,9 @@ func (s sqlIslandRepository) SetBook(ctx context.Context, book domain.Book) erro func (s sqlIslandRepository) GetBook(ctx context.Context, bookId string) (*domain.Book, error) { var content []byte err := s.db.QueryRowContext(ctx, `SELECT content FROM books WHERE id = $1`, bookId).Scan(&content) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrBookNotFound + } if err != nil { return nil, fmt.Errorf("get content of book of island: %w", err) } @@ -159,7 +162,7 @@ func (s sqlIslandRepository) scanIslandHeader(row scannable, header *domain.Isla } func (s sqlIslandRepository) GetIslandHeadersByTerritory(ctx context.Context, territoryId string) (result []domain.IslandHeader, err error) { - rows, err := s.db.QueryContext(ctx, `SELECT `+s.islandHeaderColumnsToSelect()+` WHERE territory_id = $1`, territoryId) + rows, err := s.db.QueryContext(ctx, `SELECT `+s.islandHeaderColumnsToSelect()+`FROM islands WHERE territory_id = $1`, territoryId) if err != nil { return nil, fmt.Errorf("get island headers by territory %q: %w", territoryId, err) } @@ -265,7 +268,9 @@ func (s sqlIslandRepository) GetTerritoryPoolSettings(ctx context.Context, terri err := s.db.QueryRowContext(ctx, `SELECT easy, medium, hard FROM territory_pool_settings WHERE territory_id = $1`, territoryId). Scan(&settings.Easy, &settings.Medium, &settings.Hard) - + if errors.Is(err, sql.ErrNoRows) { + return domain.TerritoryPoolSettings{}, domain.ErrPoolSettingsNotFound + } if err != nil { return domain.TerritoryPoolSettings{}, err } @@ -278,6 +283,25 @@ func (s sqlIslandRepository) AddBookToPool(ctx context.Context, poolId string, b return err } +func (s sqlIslandRepository) GetBooksInPool(ctx context.Context, poolId string) (bookIds []string, err error) { + rows, err := s.db.QueryContext(ctx, `SELECT book_id FROM book_pools WHERE pool_id = $1`, poolId) + if err != nil { + return nil, err + } + defer func() { + err = rows.Close() + }() + for rows.Next() { + var bookId string + if err := rows.Scan(&bookId); err != nil { + return nil, err + } + bookIds = append(bookIds, bookId) + } + + return bookIds, nil +} + func (s sqlIslandRepository) GetPoolOfBook(ctx context.Context, bookId string) (poolId string, found bool, err error) { err = s.db.QueryRowContext(ctx, `SELECT pool_id FROM book_pools WHERE book_id = $1`, bookId).Scan(&poolId) found = err == nil diff --git a/backend/internal/repository/question.go b/backend/internal/repository/question.go index d70e764..df93d4c 100644 --- a/backend/internal/repository/question.go +++ b/backend/internal/repository/question.go @@ -3,9 +3,11 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" + "slices" "time" "github.com/Rastaiha/bermudia/internal/domain" @@ -15,8 +17,10 @@ const ( questionsSchema = ` CREATE TABLE IF NOT EXISTS questions ( question_id VARCHAR(255) PRIMARY KEY, - book_id VARCHAR(255) NOT NULL, + book_id VARCHAR(255) NOT NULL REFERENCES books(id), text TEXT NOT NULL, + input_type VARCHAR(255) NOT NULL, + input_accept TEXT NOT NULL, context TEXT NOT NULL, knowledge_amount INT4 NOT NULL, reward_source VARCHAR(255) @@ -26,7 +30,7 @@ CREATE INDEX IF NOT EXISTS idx_questions_book_id ON questions (book_id); answersSchema = ` CREATE TABLE IF NOT EXISTS answers ( user_id INT4 NOT NULL, - question_id VARCHAR(255) NOT NULL, + question_id VARCHAR(255) NOT NULL REFERENCES questions(question_id), status INT4 NOT NULL, requested_help BOOLEAN NOT NULL DEFAULT FALSE, help_state INT NOT NULL DEFAULT 0, @@ -88,20 +92,48 @@ func (s sqlQuestionRepository) BindQuestionsToBook(ctx context.Context, bookId s err = tx.Commit() } }() - _, err = tx.ExecContext(ctx, `DELETE FROM questions WHERE book_id = $1`, bookId) + + var bookQuestionsBeforeChange []string + rows, err := tx.QueryContext(ctx, `SELECT question_id FROM questions WHERE book_id = $1`, bookId) if err != nil { - return fmt.Errorf("delete questions: %w", err) + return fmt.Errorf("failed to query current book questions: %w", err) } + defer rows.Close() + for rows.Next() { + var questionId string + if err := rows.Scan(&questionId); err != nil { + return fmt.Errorf("failed to scan current book question: %w", err) + } + bookQuestionsBeforeChange = append(bookQuestionsBeforeChange, questionId) + } + for _, q := range questions { + inputAccept, err := json.Marshal(q.InputAccept) + if err != nil { + return fmt.Errorf("failed to marshal input_accept: %w", err) + } _, err = tx.ExecContext(ctx, - `INSERT INTO questions (question_id, book_id, text, context, knowledge_amount, reward_source) VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (question_id) DO UPDATE SET book_id = $2, text = $3, context = $4, knowledge_amount = $5, reward_source = $6`, - n(q.QuestionID), n(bookId), n(q.Text), q.Context, q.KnowledgeAmount, n(q.RewardSource), + `INSERT INTO questions (question_id, book_id, text, input_type, input_accept, context, knowledge_amount, reward_source) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (question_id) DO UPDATE SET book_id = $2, text = $3, input_type = $4, input_accept = $5, context = $6, knowledge_amount = $7, reward_source = $8`, + n(q.QuestionID), n(bookId), n(q.Text), n(q.InputType), inputAccept, q.Context, q.KnowledgeAmount, n(q.RewardSource), ) if err != nil { return fmt.Errorf("insert questions: %w", err) } } + + for _, questionId := range bookQuestionsBeforeChange { + if slices.ContainsFunc(questions, func(question domain.BookQuestion) bool { + return question.QuestionID == questionId + }) { + continue + } + _, err := tx.ExecContext(ctx, `DELETE FROM questions WHERE question_id = $1`, questionId) + if err != nil { + return fmt.Errorf("failed to delete obsolete question %q in book %q: %w", questionId, bookId, err) + } + } + return nil } @@ -258,8 +290,10 @@ WHERE i.id = $4 ; func (s sqlQuestionRepository) GetQuestion(ctx context.Context, questionId string) (domain.BookQuestion, error) { var question domain.BookQuestion var rewardSource sql.NullString - err := s.db.QueryRowContext(ctx, `SELECT question_id, book_id, text, context, knowledge_amount, reward_source FROM questions WHERE question_id = $1 ;`, - questionId).Scan(&question.QuestionID, &question.BookID, &question.Text, &question.Context, &question.KnowledgeAmount, &rewardSource) + var inputAccept string + err := s.db.QueryRowContext(ctx, `SELECT question_id, book_id, text, input_type, input_accept, context, knowledge_amount, reward_source FROM questions WHERE question_id = $1 ;`, + questionId).Scan(&question.QuestionID, &question.BookID, &question.Text, &question.InputType, &inputAccept, &question.Context, &question.KnowledgeAmount, &rewardSource) + _ = json.Unmarshal([]byte(inputAccept), &question.InputAccept) question.RewardSource = rewardSource.String if errors.Is(err, sql.ErrNoRows) { return question, domain.ErrQuestionNotFound @@ -267,6 +301,29 @@ func (s sqlQuestionRepository) GetQuestion(ctx context.Context, questionId strin return question, err } +func (s sqlQuestionRepository) GetQuestions(ctx context.Context, bookId string) ([]domain.BookQuestion, error) { + var questions []domain.BookQuestion + rows, err := s.db.QueryContext(ctx, `SELECT question_id, book_id, text, input_type, input_accept, context, knowledge_amount, reward_source FROM questions WHERE book_id = $1 ;`, + bookId) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var question domain.BookQuestion + var rewardSource sql.NullString + var inputAccept string + err = rows.Scan(&question.QuestionID, &question.BookID, &question.Text, &question.InputType, &inputAccept, &question.Context, &question.KnowledgeAmount, &rewardSource) + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(inputAccept), &question.InputAccept) + question.RewardSource = rewardSource.String + questions = append(questions, question) + } + return questions, nil +} + func (s sqlQuestionRepository) CreateCorrection(ctx context.Context, correction domain.Correction) error { correction.UpdatedAt = time.Now().UTC() _, err := s.db.ExecContext(ctx, diff --git a/backend/internal/repository/treasure.go b/backend/internal/repository/treasure.go index 9335c45..952a12b 100644 --- a/backend/internal/repository/treasure.go +++ b/backend/internal/repository/treasure.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "time" "github.com/Rastaiha/bermudia/internal/domain" @@ -15,14 +16,14 @@ const ( treasuresSchema = ` CREATE TABLE IF NOT EXISTS treasures ( id VARCHAR(255) PRIMARY KEY, - book_id VARCHAR(255) NOT NULL + book_id VARCHAR(255) NOT NULL REFERENCES books(id) ); CREATE INDEX IF NOT EXISTS idx_treasures_book_id ON treasures (book_id); ` userTreasuresSchema = ` CREATE TABLE IF NOT EXISTS user_treasures ( user_id INT4 NOT NULL, - treasure_id VARCHAR(255) NOT NULL, + treasure_id VARCHAR(255) NOT NULL REFERENCES treasures(id), unlocked BOOLEAN NOT NULL, cost TEXT NOT NULL, alt_cost TEXT NOT NULL, @@ -64,10 +65,23 @@ func (s sqlTreasureRepository) BindTreasuresToBook(ctx context.Context, bookId s err = tx.Commit() } }() - _, err = tx.ExecContext(ctx, `DELETE FROM treasures WHERE book_id = $1`, bookId) + + var bookTreasuresBeforeChange []string + rows, err := tx.QueryContext(ctx, `SELECT id FROM treasures WHERE book_id = $1`, bookId) if err != nil { - return fmt.Errorf("delete treasures: %w", err) + return fmt.Errorf("failed to query current book questions: %w", err) + } + for rows.Next() { + var treasureId string + if err := rows.Scan(&treasureId); err != nil { + return fmt.Errorf("failed to scan current book question: %w", err) + } + bookTreasuresBeforeChange = append(bookTreasuresBeforeChange, treasureId) } + if err := rows.Close(); err != nil { + return err + } + for _, t := range treasures { _, err = tx.ExecContext(ctx, `INSERT INTO treasures (id, book_id) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET book_id = $2`, n(t.ID), n(bookId), @@ -76,6 +90,19 @@ func (s sqlTreasureRepository) BindTreasuresToBook(ctx context.Context, bookId s return fmt.Errorf("insert treasures: %w", err) } } + + for _, tId := range bookTreasuresBeforeChange { + if slices.ContainsFunc(treasures, func(treasure domain.Treasure) bool { + return treasure.ID == tId + }) { + continue + } + _, err := tx.ExecContext(ctx, `DELETE FROM treasures WHERE id = $1`, tId) + if err != nil { + return fmt.Errorf("failed to delete obsolete treasure %q in book %q: %w", tId, bookId, err) + } + } + return nil } @@ -146,6 +173,25 @@ func (s sqlTreasureRepository) GetTreasure(ctx context.Context, treasureId strin return treasure, err } +func (s sqlTreasureRepository) GetTreasures(ctx context.Context, bookId string) (treasures []domain.Treasure, err error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, book_id FROM treasures WHERE book_id = $1`, bookId) + if err != nil { + return nil, fmt.Errorf("failed to get treasures: %w", err) + } + defer func() { + err = rows.Close() + }() + for rows.Next() { + var treasure domain.Treasure + err := rows.Scan(&treasure.ID, &treasure.BookID) + if err != nil { + return nil, fmt.Errorf("failed to scan treasures: %w", err) + } + treasures = append(treasures, treasure) + } + return treasures, nil +} + func (s sqlTreasureRepository) GetUserTreasure(ctx context.Context, userId int32, treasureId string) (domain.UserTreasure, error) { var userTreasure domain.UserTreasure err := s.scanUserTreasure(s.db.QueryRowContext(ctx, diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go index 55db1e5..453c02e 100644 --- a/backend/internal/repository/user.go +++ b/backend/internal/repository/user.go @@ -40,7 +40,7 @@ func (s sqlUser) columns() string { return "SELECT id, username_display, meet_link, hashed_password, name FROM users" } -func (s sqlUser) scan(row *sql.Row, user *domain.User) error { +func (s sqlUser) scan(row scannable, user *domain.User) error { err := row.Scan(&user.ID, &user.Username, &user.MeetLink, &user.HashedPassword, &user.Name) if errors.Is(err, sql.ErrNoRows) { return domain.ErrUserNotFound @@ -72,3 +72,22 @@ func (s sqlUser) GetByUsername(ctx context.Context, username string) (*domain.Us err := s.scan(s.db.QueryRowContext(ctx, s.columns()+" WHERE username = $1", strings.ToLower(username)), &result) return &result, err } + +func (s sqlUser) GetAll(ctx context.Context) (users []domain.User, err error) { + rows, err := s.db.QueryContext(ctx, s.columns()) + if err != nil { + return nil, err + } + defer func() { + err = rows.Close() + }() + for rows.Next() { + user := domain.User{} + err := s.scan(rows, &user) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index b22ddd8..97b7dfe 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -8,8 +8,10 @@ import ( "fmt" "github.com/Rastaiha/bermudia/internal/config" "github.com/Rastaiha/bermudia/internal/domain" + "github.com/golang-jwt/jwt/v5" "math/rand" "slices" + "time" ) type Admin struct { @@ -34,14 +36,24 @@ func NewAdmin(cfg config.Config, territoryStore domain.TerritoryStore, islandSto } } +func (a *Admin) GetTerritories(ctx context.Context) ([]domain.Territory, error) { + return a.territoryStore.ListTerritories(ctx) +} + func (a *Admin) SetTerritory(ctx context.Context, territory domain.Territory) error { + if territory.ID == "" { + return AdminError{"id is required"} + } for _, island := range territory.Islands { if island.ID == "" { - return fmt.Errorf("empty island id in island list") + return AdminError{"empty island id in island list"} + } + if island.Name == "" { + return AdminError{"empty island name in island list"} } } if territory.StartIsland == "" { - return errors.New("invalid territory startIsland") + return AdminError{"invalid territory startIsland"} } isInIslands := func(id string) bool { return slices.ContainsFunc(territory.Islands, func(island domain.Island) bool { @@ -49,36 +61,36 @@ func (a *Admin) SetTerritory(ctx context.Context, territory domain.Territory) er }) } if !isInIslands(territory.StartIsland) { - return fmt.Errorf("startIsland %q not found in island list", territory.StartIsland) + return AdminError{fmt.Sprintf("startIsland %q not found in island list", territory.StartIsland)} } for _, e := range territory.Edges { if e.From == "" || e.To == "" { - return fmt.Errorf("empty edge.from or edge.to: %v", e) + return AdminError{fmt.Sprintf("empty edge.from or edge.to: %v", e)} } if !isInIslands(e.From) { - return fmt.Errorf("edge.from %q is not in island list", e.From) + return AdminError{fmt.Sprintf("edge.from %q is not in island list", e.From)} } if !isInIslands(e.To) { - return fmt.Errorf("edge.to %q is not in island list", e.To) + return AdminError{fmt.Sprintf("edge.to %q is not in island list", e.To)} } } for _, r := range territory.RefuelIslands { if !isInIslands(r.ID) { - return fmt.Errorf("refuelIsland %q not found in island list", r.ID) + return AdminError{fmt.Sprintf("refuelIsland %q not found in island list", r.ID)} } } for _, t := range territory.TerminalIslands { if !isInIslands(t.ID) { - return fmt.Errorf("terminalIsland %q not found in island list", t.ID) + return AdminError{fmt.Sprintf("terminalIsland %q not found in island list", t.ID)} } } for islandID, prerequisites := range territory.IslandPrerequisites { if !isInIslands(islandID) { - return fmt.Errorf("island %q in prerequisites not found in island list", islandID) + return AdminError{fmt.Sprintf("island %q in prerequisites not found in island list", islandID)} } for _, p := range prerequisites { if !isInIslands(p) { - return fmt.Errorf("prerequisite %q not found in island list", p) + return AdminError{fmt.Sprintf("prerequisite %q not found in island list", p)} } } } @@ -95,6 +107,10 @@ func (a *Admin) SetTerritory(ctx context.Context, territory domain.Territory) er return a.territoryStore.SetTerritory(ctx, &territory) } +func (a *Admin) GetIslandHeader(ctx context.Context, islandId string) (domain.IslandHeader, error) { + return a.islandStore.GetIslandHeader(ctx, islandId) +} + type BookInput struct { BookId string `json:"bookId"` Components []*BookInputComponent `json:"components"` @@ -111,16 +127,19 @@ type BookInputComponent struct { } type IslandInputQuestion struct { - domain.Question - KnowledgeAmount int32 `json:"knowledgeAmount"` - RewardSource string `json:"rewardSource,omitempty"` - Context string `json:"correctionHintMessage,omitempty"` + ID string `json:"id"` + Text string `json:"text"` + InputType string `json:"inputType"` + InputAccept []string `json:"inputAccept"` + KnowledgeAmount int32 `json:"knowledgeAmount"` + RewardSource string `json:"rewardSource,omitempty"` + Context string `json:"correctionHintMessage,omitempty"` } func (a *Admin) SetBookAndBindToIsland(ctx context.Context, islandId string, input BookInput) (BookInput, error) { territoryId, err := a.islandStore.GetTerritory(ctx, islandId) if err != nil { - return input, fmt.Errorf("island %q does not have territory", islandId) + return input, err } input, err = a.setBook(ctx, input) if err != nil { @@ -140,7 +159,7 @@ func (a *Admin) SetBookAndBindToIsland(ctx context.Context, islandId string, inp func (a *Admin) SetBookAndBindToPool(ctx context.Context, poolId string, input BookInput) (BookInput, error) { if !domain.IsPoolIdValid(poolId) { - return input, fmt.Errorf("invalid poolId %q", poolId) + return input, AdminError{fmt.Sprintf("invalid poolId %q", poolId)} } input, err := a.setBook(ctx, input) if err != nil { @@ -154,56 +173,65 @@ func (a *Admin) SetBookAndBindToPool(ctx context.Context, poolId string, input B } func (a *Admin) setBook(ctx context.Context, input BookInput) (BookInput, error) { - if input.BookId == "" || !domain.IdHasType(input.BookId, domain.ResourceTypeBook) { + if input.BookId == "" { input.BookId = domain.NewID(domain.ResourceTypeBook) + } else if !domain.IdHasType(input.BookId, domain.ResourceTypeBook) { + return input, AdminError{fmt.Sprintf("invalid bookId %q", input.BookId)} } book := domain.Book{ID: input.BookId, Components: make([]domain.BookComponent, 0)} var questions []domain.BookQuestion + var treasures []domain.Treasure for i, c := range input.Components { if c.IFrame != nil { if c.IFrame.Url == "" { - return input, fmt.Errorf("empty url for book %q iframe component at index %d", book.ID, i) + return input, AdminError{fmt.Sprintf("empty url for book %q iframe component at index %d", book.ID, i)} } book.Components = append(book.Components, domain.BookComponent{IFrame: c.IFrame}) continue } if c.Question != nil { if c.Question.InputType == "" { - return input, fmt.Errorf("empty inputType for book %q question at index %d", book.ID, i) + return input, AdminError{fmt.Sprintf("empty inputType for book %q question at index %d", book.ID, i)} } if c.Question.InputType == "file" && len(c.Question.InputAccept) == 0 { - return input, fmt.Errorf("empty inputAccept for book %q question at index %d", book.ID, i) + return input, AdminError{fmt.Sprintf("empty inputAccept for book %q question at index %d", book.ID, i)} } if c.Question.KnowledgeAmount < 0 { - return input, fmt.Errorf("negative knowledgeAmount for book %q question at index %d", book.ID, i) + return input, AdminError{fmt.Sprintf("negative knowledgeAmount for book %q question at index %d", book.ID, i)} } if !domain.IsValidRewardSource(c.Question.RewardSource) { - return input, fmt.Errorf("invalid reward source %q", c.Question.RewardSource) + return input, AdminError{fmt.Sprintf("invalid reward source %q", c.Question.RewardSource)} } if c.Question.Text == "" { - return input, fmt.Errorf("empty text for book %q question at index %d", book.ID, i) + return input, AdminError{fmt.Sprintf("empty text for book %q question at index %d", book.ID, i)} } - if c.Question.ID == "" || !domain.IdHasType(c.Question.ID, domain.ResourceTypeQuestion) { + if c.Question.ID == "" { c.Question.ID = domain.NewID(domain.ResourceTypeQuestion) + } else if !domain.IdHasType(c.Question.ID, domain.ResourceTypeQuestion) { + return input, AdminError{fmt.Sprintf("invalid question id %q", c.Question.ID)} } questions = append(questions, domain.BookQuestion{ QuestionID: c.Question.ID, BookID: input.BookId, Text: c.Question.Text, + InputType: c.Question.InputType, + InputAccept: c.Question.InputAccept, KnowledgeAmount: c.Question.KnowledgeAmount, RewardSource: c.Question.RewardSource, Context: c.Question.Context, }) - book.Components = append(book.Components, domain.BookComponent{Question: &c.Question.Question}) + book.Components = append(book.Components, domain.BookComponent{Question: &domain.QuestionPlaceholder{ID: c.Question.ID}}) continue } - return input, fmt.Errorf("unknown component for book %q at index %d", book.ID, i) + return input, AdminError{fmt.Sprintf("unknown component for book %q at index %d", book.ID, i)} } for _, t := range input.Treasures { - if t.ID == "" || !domain.IdHasType(t.ID, domain.ResourceTypeTreasure) { + if t.ID == "" { t.ID = domain.NewID(domain.ResourceTypeTreasure) + } else if !domain.IdHasType(t.ID, domain.ResourceTypeTreasure) { + return input, AdminError{fmt.Sprintf("invalid treasure id %q", t.ID)} } - book.Treasures = append(book.Treasures, domain.Treasure{ID: t.ID, BookID: input.BookId}) + treasures = append(treasures, domain.Treasure{ID: t.ID, BookID: input.BookId}) } err := a.islandStore.SetBook(ctx, book) if err != nil { @@ -213,13 +241,91 @@ func (a *Admin) setBook(ctx context.Context, input BookInput) (BookInput, error) if err != nil { return input, fmt.Errorf("failed to bind questions to book: %w", err) } - err = a.treasureStore.BindTreasuresToBook(ctx, book.ID, book.Treasures) + err = a.treasureStore.BindTreasuresToBook(ctx, book.ID, treasures) if err != nil { return input, fmt.Errorf("failed to bind treasures to book: %w", err) } return input, nil } +func (a *Admin) GetBook(ctx context.Context, bookId string) (BookInput, error) { + book, err := a.islandStore.GetBook(ctx, bookId) + if err != nil { + return BookInput{}, err + } + bookQuestions, err := a.questionStore.GetQuestions(ctx, bookId) + if err != nil { + return BookInput{}, err + } + treasures, err := a.treasureStore.GetTreasures(ctx, bookId) + if err != nil { + return BookInput{}, err + } + + islandInputQuestions := make(map[string]IslandInputQuestion) + for _, q := range bookQuestions { + islandInputQuestions[q.QuestionID] = IslandInputQuestion{ + ID: q.QuestionID, + Text: q.Text, + InputType: q.InputType, + InputAccept: q.InputAccept, + KnowledgeAmount: q.KnowledgeAmount, + RewardSource: q.RewardSource, + Context: q.Context, + } + } + result := BookInput{ + BookId: book.ID, + } + for _, c := range book.Components { + if c.IFrame != nil { + result.Components = append(result.Components, &BookInputComponent{ + IFrame: c.IFrame, + }) + continue + } + if c.Question != nil { + q, ok := islandInputQuestions[c.Question.ID] + if !ok { + q.ID = c.Question.ID + } + result.Components = append(result.Components, &BookInputComponent{Question: &q}) + delete(islandInputQuestions, c.Question.ID) + continue + } + } + for _, q := range islandInputQuestions { + result.Components = append(result.Components, &BookInputComponent{Question: &q}) + } + for _, t := range treasures { + result.Treasures = append(result.Treasures, &BookTreasureComponent{ + ID: t.ID, + }) + } + + return result, nil +} + +type PoolOutput struct { + ID string `json:"id"` + Books []string `json:"books"` +} + +func (a *Admin) GetPools(ctx context.Context) ([]PoolOutput, error) { + var result []PoolOutput + for _, poolId := range domain.PoolIds() { + books, err := a.islandStore.GetBooksInPool(ctx, poolId) + if err != nil { + return result, err + } + result = append(result, PoolOutput{ + ID: poolId, + Books: books, + }) + } + return result, nil +} + type TerritoryIslandBindings struct { TerritoryId string `json:"territoryId"` EmptyIslands []string `json:"emptyIslands"` @@ -231,6 +337,9 @@ func (a *Admin) GetTerritoryIslandBindings(ctx context.Context, territoryId stri binding := TerritoryIslandBindings{ TerritoryId: territoryId, } + if _, err := a.territoryStore.GetTerritoryByID(ctx, binding.TerritoryId); err != nil { + return binding, err + } islands, err := a.islandStore.GetIslandHeadersByTerritory(ctx, territoryId) if err != nil { return binding, fmt.Errorf("failed to get island headers by territory %q: %w", territoryId, err) @@ -240,10 +349,14 @@ func (a *Admin) GetTerritoryIslandBindings(ctx context.Context, territoryId stri binding.PooledIslands = append(binding.PooledIslands, h.ID) } if !h.FromPool && h.BookID == "" { - binding.PooledIslands = append(binding.EmptyIslands, h.ID) + binding.EmptyIslands = append(binding.EmptyIslands, h.ID) } } settings, err := a.islandStore.GetTerritoryPoolSettings(ctx, territoryId) + if errors.Is(err, domain.ErrPoolSettingsNotFound) { + err = nil + settings = domain.TerritoryPoolSettings{} + } if err != nil { return binding, err } @@ -254,7 +367,10 @@ func (a *Admin) GetTerritoryIslandBindings(ctx context.Context, territoryId stri func (a *Admin) SetTerritoryIslandBindings(ctx context.Context, bindings TerritoryIslandBindings) (TerritoryIslandBindings, error) { pooledCount := int32(len(bindings.PooledIslands)) if pooledCount != bindings.PoolSettings.TotalCount() { - return bindings, fmt.Errorf("number of pooled islands don't match pool settings: %d vs %d", pooledCount, bindings.PoolSettings.TotalCount()) + return bindings, AdminError{fmt.Sprintf("number of pooled islands don't match pool settings: %d vs %d", pooledCount, bindings.PoolSettings.TotalCount())} + } + if _, err := a.territoryStore.GetTerritoryByID(ctx, bindings.TerritoryId); err != nil { + return bindings, err } err := a.islandStore.SetTerritoryPoolSettings(ctx, bindings.TerritoryId, bindings.PoolSettings) if err != nil { @@ -288,46 +404,39 @@ func (a *Admin) SetTerritoryIslandBindings(ctx context.Context, bindings Territo type User struct { Name string `json:"name"` Username string `json:"username"` - Password string `json:"password"` - StartingTerritory string `json:"startingTerritory"` + Password string `json:"password,omitempty"` + StartingTerritory string `json:"startingTerritory,omitempty"` MeetLink string `json:"meetLink"` } -func (a *Admin) CreateUser(ctx context.Context, index int, user User) (User, error) { +func (a *Admin) CreateUser(ctx context.Context, user User) (User, error) { if user.Username == "" { - return User{}, fmt.Errorf("username is required") + return User{}, AdminError{"username is required"} } if user.Password == "" { b := make([]byte, 8) _, _ = cRand.Read(b) user.Password = base64.RawURLEncoding.EncodeToString(b) } - territories, err := a.territoryStore.ListTerritories(ctx) - if err != nil { - return user, err - } - if len(territories) == 0 { - return user, errors.New("no territory found") + if user.StartingTerritory == "" { + return User{}, AdminError{"startingTerritory is required"} } - id := rand.Int31() - startingTerritory := territories[index%len(territories)] - if startingTerritory.ID != "" { - for _, t := range territories { - if t.ID == user.StartingTerritory { - startingTerritory = t - break - } - } + startingTerritory, err := a.territoryStore.GetTerritoryByID(ctx, user.StartingTerritory) + if errors.Is(err, domain.ErrTerritoryNotFound) { + return user, AdminError{fmt.Sprintf("failed to find starting territory %q", user.StartingTerritory)} + } + if err != nil { + return user, err } - user.StartingTerritory = startingTerritory.ID hp, err := domain.HashPassword(user.Password) if err != nil { return user, err } + u := &domain.User{ - ID: id, + ID: rand.Int31(), Username: user.Username, Name: user.Name, MeetLink: user.MeetLink, @@ -336,5 +445,77 @@ func (a *Admin) CreateUser(ctx context.Context, index int, user User) (User, err if err := a.userStore.Create(ctx, u); err != nil { return user, err } - return user, a.playerStore.Create(ctx, domain.NewPlayer(u.ID, &startingTerritory)) + return user, a.playerStore.Create(ctx, domain.NewPlayer(u.ID, startingTerritory)) +} + +func (a *Admin) GetUsers(ctx context.Context) ([]User, error) { + users, err := a.userStore.GetAll(ctx) + if err != nil { + return nil, err + } + result := make([]User, 0, len(users)) + for _, u := range users { + result = append(result, User{ + Name: u.Name, + Username: u.Username, + MeetLink: u.MeetLink, + }) + } + return result, nil +} + +func (a *Admin) Login(_ context.Context, username string, password string) (string, error) { + if username == "" || password == "" { + return "", domain.ErrUserNotFound + } + if username != a.cfg.AdminUsername || password != a.cfg.AdminPassword { + return "", domain.ErrUserNotFound + } + token := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{ + "admin": true, + "iat": float64(time.Now().UTC().UnixNano()) / 1e9, + }) + tokenString, err := token.SignedString(a.cfg.TokenSigningKeyBytes()) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + return tokenString, nil +} + +func (a *Admin) ValidateToken(_ context.Context, tokenStr string) bool { + token, err := jwt.Parse( + tokenStr, + func(token *jwt.Token) (interface{}, error) { + return a.cfg.TokenSigningKeyBytes(), nil + }, + jwt.WithValidMethods([]string{jwt.SigningMethodHS512.Alg()}), + jwt.WithIssuedAt(), + ) + if err != nil { + return false + } + if !token.Valid { + return false + } + if iat, err := token.Claims.GetIssuedAt(); err != nil || time.Since(iat.Time) > 6*time.Hour { + return false + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return false + } + v, ok := claims["admin"] + if !ok { + return false + } + isAdmin, _ := v.(bool) + return isAdmin +} + +type AdminError struct { + text string +} + +func (e AdminError) Error() string { + return e.text } diff --git a/backend/internal/service/island.go b/backend/internal/service/island.go index 02534d3..39e7ebc 100644 --- a/backend/internal/service/island.go +++ b/backend/internal/service/island.go @@ -123,7 +123,31 @@ func (i *Island) GetIsland(ctx context.Context, userId int32, islandId string) ( if err != nil { return nil, err } + bookQuestions, err := i.questionStore.GetQuestions(ctx, bookId) + if err != nil { + return nil, err + } + treasures, err := i.treasureStore.GetTreasures(ctx, bookId) + if err != nil { + return nil, err + } + questionComponents := make(map[string]domain.IslandComponent) + for _, question := range bookQuestions { + answer, err := i.questionStore.GetOrCreateAnswer(ctx, userId, question.QuestionID) + if err != nil { + return nil, err + } + questionComponents[question.QuestionID] = domain.IslandComponent{ + Input: &domain.IslandInput{ + ID: question.QuestionID, + Type: question.InputType, + Accept: question.InputAccept, + Description: question.Text, + SubmissionState: domain.GetSubmissionState(question, answer), + }, + } + } content := &domain.IslandContent{} for _, c := range book.Components { if c.IFrame != nil { @@ -131,27 +155,25 @@ func (i *Island) GetIsland(ctx context.Context, userId int32, islandId string) ( continue } if c.Question != nil { - question, err := i.questionStore.GetQuestion(ctx, c.Question.ID) - if err != nil { - return nil, err + q, ok := questionComponents[c.Question.ID] + if ok { + content.Components = append(content.Components, q) + delete(questionComponents, c.Question.ID) + } else { + slog.Warn("book questions configuration mismatch: missing question", slog.String("bookId", bookId), slog.String("questionId", c.Question.ID)) } - answer, err := i.questionStore.GetOrCreateAnswer(ctx, userId, c.Question.ID) - if err != nil { - return nil, err - } - content.Components = append(content.Components, domain.IslandComponent{ - Input: &domain.IslandInput{ - ID: c.Question.ID, - Type: c.Question.InputType, - Accept: c.Question.InputAccept, - Description: c.Question.Text, - SubmissionState: domain.GetSubmissionState(question, answer), - }, - }) continue } } - for _, t := range book.Treasures { + // questionComponents should be empty here, + // but in case something has gone wrong in configuration, just append the remaining questions so users can answer them + if len(questionComponents) > 0 { + slog.Warn("book questions configuration mismatch", slog.String("bookId", bookId)) + } + for _, q := range questionComponents { + content.Components = append(content.Components, q) + } + for _, t := range treasures { userTreasure, err := i.treasureStore.GetOrCreateUserTreasure(ctx, userId, t.ID) if err != nil { return nil, err diff --git a/backend/main.go b/backend/main.go index d61d3b7..2fd41c8 100644 --- a/backend/main.go +++ b/backend/main.go @@ -96,7 +96,7 @@ func main() { } } - h := handler.New(cfg, authService, territoryService, islandService, playerService) + h := handler.New(cfg, authService, adminService, territoryService, islandService, playerService) adminBot := adminbot.NewBot(cfg, theBot, h, islandService, correctionService, playerService, adminService, userRepo, gameStateRepo) diff --git a/docs/admin-api.md b/docs/admin-api.md new file mode 100644 index 0000000..6a7e4c4 --- /dev/null +++ b/docs/admin-api.md @@ -0,0 +1,457 @@ +# Admin API Documentation + +## Base URL + +``` +{{protocol}}://bermudia-api-internal.darkube.app/admin +``` + +## Response Format + +All endpoints return responses in the same format as the main API: + +```json +{ + "ok": true, + "error": "string", + "result": {} +} +``` + +- `ok`: Boolean indicating success/failure +- `error`: Error message (only present when `ok=false`) +- `result`: Response data (only present when `ok=true`) + +## Authentication + +All admin endpoints (except [Admin Login](#admin-login)) require a valid admin JWT token. + +The token must be sent in the `Authorization` request header: + +``` +Authorization: Bearer +``` + +Admin tokens are valid for **6 hours** from the time of issuance. + +--- + +## Endpoints + +### Admin Login + +Authenticates an admin user and returns a JWT token. + +**Endpoint:** `POST /admin/login` + +**Request Body:** + +| Field | Type | Required | Description | +|----------|--------|----------|--------------------| +| username | string | yes | Admin username | +| password | string | yes | Admin password | + +**Response:** [`AdminLoginResult`](#adminloginresult) + +--- + +### Get Territories + +_**Requires authentication.**_ + +Returns a list of all territories in the game. + +**Endpoint:** `GET /admin/territories` + +**Response:** Array of [`Territory`](#territory) + +--- + +### Set Territory + +_**Requires authentication.**_ + +Creates or updates a territory. The territory is identified by its `id` field. If a territory with the given `id` already exists, it will be fully replaced. Islands listed in the territory that do not yet exist in the database will be created (their IDs reserved). + +**Endpoint:** `POST /admin/territories` + +**Request Body:** [`Territory`](#territory) + +**Response:** [`Territory`](#territory) — the territory as submitted + +**Validation rules:** +- `id` is required +- `startIsland` is required and must refer to one of the islands in the `islands` array +- All islands in `islands` must have a non-empty `id` and `name` +- All edges must refer to islands in the `islands` array +- All `refuelIslands` and `terminalIslands` must refer to islands in the `islands` array +- All keys in `islandPrerequisites` and their prerequisite values must refer to islands in the `islands` array + +--- + +### Get Territory Island Bindings + +_**Requires authentication.**_ + +Returns the current island bindings and pool settings for a territory. Shows which islands have a directly-assigned book, which are assigned from a pool, and which are empty. + +**Endpoint:** `GET /admin/territories/{territoryID}/island_bindings` + +**Path Parameters:** + +| Parameter | Description | +|-------------|-------------------------------------| +| territoryID | The unique identifier of the territory | + +**Response:** [`TerritoryIslandBindings`](#territoryislandbindings) + +--- + +### Set Territory Island Bindings + +_**Requires authentication.**_ + +Updates the island bindings and pool settings for a territory. This determines which islands draw their content from a pool of books (assigned randomly per player) and which are empty. + +**Endpoint:** `POST /admin/territories/{territoryID}/island_bindings` + +**Request Body:** [`TerritoryIslandBindings`](#territoryislandbindings) + +**Response:** [`TerritoryIslandBindings`](#territoryislandbindings) + +**Note:** The total count of islands in `pooledIslands` must equal `poolSettings.easy + poolSettings.medium + poolSettings.hard`, otherwise the request is rejected. + +--- + +### Get Island Header + +_**Requires authentication.**_ + +Returns the header metadata for a single island, including whether it has a book assigned and whether that book is from a pool. + +**Endpoint:** `GET /admin/islands/{islandID}` + +**Path Parameters:** + +| Parameter | Description | +|-----------|-----------------------------------| +| islandID | The unique identifier of the island | + +**Response:** [`IslandHeader`](#islandheader) + +--- + +### Set Book and Bind to Island + +_**Requires authentication.**_ + +Creates or updates a book and directly binds it to the given island. The operation is upsert-style: if `bookId` is omitted from the request body, a new book is created and a new ID is generated and returned. If a `bookId` from a previous response is provided, the existing book is updated in place. + +The same logic applies to individual components within the book: +- If a question's `id` field is omitted, a new question is created with a generated ID. +- If a question's `id` (e.g., `qst_...`) from a previous response is included, that question is updated. +- If a treasure's `id` field is omitted, a new treasure is created. +- If a treasure's `id` from a previous response is included, that treasure entry is updated. + +**Endpoint:** `POST /admin/islands/{islandID}/book` + +**Path Parameters:** + +| Parameter | Description | +|-----------|-----------------------------------| +| islandID | The unique identifier of the island | + +**Request Body:** [`BookInput`](#bookinput) + +**Response:** [`BookInput`](#bookinput) — the full book as saved, with all IDs populated + +--- + +### Get Book + +_**Requires authentication.**_ + +Returns the full contents of a book, including all its components (iframes and questions) and treasures. The returned object can be modified and resubmitted to update the book. + +**Endpoint:** `GET /admin/books/{bookID}` + +**Path Parameters:** + +| Parameter | Description | +|-----------|--------------------------------| +| bookID | The unique identifier of the book | + +**Response:** [`BookInput`](#bookinput) + +--- + +### Get Pools + +_**Requires authentication.**_ + +Returns all book pools (`easy`, `medium`, `hard`) and the list of book IDs assigned to each pool. + +**Endpoint:** `GET /admin/pools` + +**Response:** Array of [`PoolOutput`](#pooloutput) + +--- + +### Set Book and Bind to Pool + +_**Requires authentication.**_ + +Creates or updates a book and adds it to the given pool. The same upsert behavior as [Set Book and Bind to Island](#set-book-and-bind-to-island) applies: omitting `bookId` creates a new book, while including an existing `bookId` updates that book. + +**Endpoint:** `POST /admin/pools/{poolID}/books` + +**Path Parameters:** + +| Parameter | Description | +|-----------|----------------------------------------------------------| +| poolID | The pool to bind the book to: `easy`, `medium`, or `hard` | + +**Request Body:** [`BookInput`](#bookinput) + +**Response:** [`BookInput`](#bookinput) — the full book as saved, with all IDs populated + +--- + +### Get Users + +_**Requires authentication.**_ + +Returns a list of all registered users. Passwords are not included in the response. + +**Endpoint:** `GET /admin/users` + +**Response:** Array of [`UserOutput`](#useroutput) + +--- + +### Create or Update User + +_**Requires authentication.**_ + +Creates a new user, or updates an existing user if the `username` already exists. When updating, all provided fields are overwritten. If `password` is omitted, a random password is generated and returned in the response — **this is the only time the password is visible**, so store it immediately. + +A player record is initialized for the user in their `startingTerritory` upon creation. If the user already exists, the player record is not re-initialized. + +**Endpoint:** `POST /admin/users` + +**Request Body:** [`UserInput`](#userinput) + +**Response:** [`UserInput`](#userinput) — includes the plaintext `password` (generated or as provided) + +--- + +## Data Types + +### AdminLoginResult + +| Field | Type | Description | +|-------|--------|------------------------------------------| +| token | string | JWT token to use for authenticated requests | + +--- + +### Territory + +| Field | Type | Description | +|---------------------|-------------------------------------------------|-------------------------------------------------------------------------------| +| id | string | Unique identifier of the territory | +| name | string | Display name | +| backgroundAsset | string | Asset key for the background image | +| startIsland | string | ID of the island where players start when entering this territory | +| islands | [`Island`](#island)[] | All islands in this territory | +| edges | [`Edge`](#edge)[] | Connections between islands that players can travel along | +| refuelIslands | [`RefuelIsland`](#refuelisland)[] | Islands where players can purchase fuel | +| terminalIslands | [`TerminalIsland`](#terminalisland)[] | Islands from which players can migrate to another territory | +| islandPrerequisites | [`IslandPrerequisites`](#islandprerequisites) | Map of island ID → list of island IDs that must be completed before access | + +--- + +### Island + +| Field | Type | Description | +|------------|--------|-------------------------------------| +| id | string | Unique identifier | +| name | string | Display name | +| x | float | X position on the territory map | +| y | float | Y position on the territory map | +| width | float | Width on the territory map | +| height | float | Height on the territory map | +| iconAsset | string | Asset key for the island icon | + +--- + +### Edge + +| Field | Type | Description | +|-------|--------|----------------------------------------| +| from | string | ID of the island at one end of the edge | +| to | string | ID of the island at the other end | + +Edges are **bidirectional** — a player can travel in either direction along an edge. + +--- + +### RefuelIsland + +| Field | Type | Description | +|-------|--------|---------------------------| +| id | string | ID of the refuel island | + +--- + +### TerminalIsland + +| Field | Type | Description | +|-------|--------|----------------------------| +| id | string | ID of the terminal island | + +--- + +### IslandPrerequisites + +A JSON object mapping an island ID (string) to an array of island IDs (strings) that must be answered/completed before the player can access it. + +```json +{ + "island_math2": ["island_math1"], + "island_math3": ["island_math1", "island_math2"] +} +``` + +--- + +### IslandHeader + +| Field | Type | Description | +|-------------|---------|---------------------------------------------------------------------| +| id | string | Unique identifier of the island | +| name | string | Display name | +| territory_id | string | ID of the territory this island belongs to | +| bookId | string | ID of the directly-assigned book, if any (empty string if none) | +| fromPool | boolean | True if this island's content is assigned from a pool | + +--- + +### BookInput + +The primary structure for creating or updating a book. This same structure is used for both requests and responses. + +| Field | Type | Required (on write) | Description | +|------------|---------------------------------------------------|---------------------|-----------------------------------------------------------------------------------------------------------| +| bookId | string | no | ID of the book (`bok_...` prefix). Omit to create a new book; include to update an existing book. | +| components | [`BookInputComponent`](#bookinputcomponent)[] | yes | Ordered list of the book's content components | +| treasures | [`BookTreasureComponent`](#booktreasurecomponent)[] | no | Treasure chests embedded in this book | + +--- + +### BookInputComponent + +Exactly one of `iframe` or `question` must be present. + +| Field | Type | Description | +|----------|---------------------------------------------------|-----------------------------------| +| iframe | [`IslandIFrame`](#islandiframe)? | An embedded iframe component | +| question | [`IslandInputQuestion`](#islandinputquestion)? | A question/answer input component | + +--- + +### IslandIFrame + +| Field | Type | Description | +|-------|--------|------------------------| +| url | string | URL to embed in the iframe | + +--- + +### IslandInputQuestion + +| Field | Type | Required | Description | +|---------------------|----------|----------|-------------------------------------------------------------------------------------------------------------------| +| id | string | no | ID of the question (`qst_...` prefix). Omit to create; include a previously returned ID to update. | +| text | string | yes | The question prompt shown to the player | +| inputType | string | yes | Type of answer input: `text` or `file` | +| inputAccept | string[] | conditional | Required when `inputType` is `file`. Accepted MIME types or file extensions. | +| knowledgeAmount | int | yes | Amount of knowledge points awarded upon correct answer (must be ≥ 0) | +| rewardSource | string | no | Reward tier for correct answers. Valid values: `edu1`, `edu2`, `edu3`, `edu4`, `edu5`, `edu6`, `final`. Omit for no reward. | +| correctionHintMessage | string | no | Optional hint/context message shown to correctors | + +--- + +### BookTreasureComponent + +| Field | Type | Description | +|-------|--------|--------------------------------------------------------------------------------------------------| +| id | string | ID of the treasure (`trs_...` prefix). Omit to create; include a previously returned ID to update. | + +--- + +### PoolOutput + +| Field | Type | Description | +|-------|----------|---------------------------------------------------| +| id | string | Pool identifier: `easy`, `medium`, or `hard` | +| books | string[] | List of book IDs currently in this pool | + +--- + +### TerritoryIslandBindings + +| Field | Type | Description | +|---------------|---------------------------------------------------------|-------------------------------------------------------------------------------------| +| territoryId | string | ID of the territory | +| emptyIslands | string[] | IDs of islands with no book assigned (not from pool, no direct book) | +| pooledIslands | string[] | IDs of islands that draw their content from a pool (assigned per-player at runtime) | +| poolSettings | [`TerritoryPoolSettings`](#territorypoolsettings) | How many pooled books belong to each difficulty pool | + +--- + +### TerritoryPoolSettings + +| Field | Type | Description | +|--------|------|---------------------------------------------------| +| easy | int | Number of pooled islands drawing from the `easy` pool | +| medium | int | Number of pooled islands drawing from the `medium` pool | +| hard | int | Number of pooled islands drawing from the `hard` pool | + +The sum `easy + medium + hard` must equal the number of IDs in `pooledIslands`. + +--- + +### UserInput + +| Field | Type | Required | Description | +|-------------------|--------|----------|-----------------------------------------------------------------------------------------| +| username | string | yes | Login username (case-insensitive). If a user with this username already exists, that user is updated. | +| name | string | no | Display name of the user | +| password | string | no | Plaintext password. If omitted, a random password is generated and returned. | +| startingTerritory | string | yes | ID of the territory where the player will start. Must be an existing territory. | +| meetLink | string | no | URL for the user's meet/video call link | + +--- + +### UserOutput + +Same fields as [`UserInput`](#userinput), but **`password` is always omitted**. + +| Field | Type | Description | +|-----------|--------|------------------------------------| +| username | string | Login username | +| name | string | Display name | +| meetLink | string | URL for the user's meet link | + +--- + +## Error Responses + +| HTTP Status | When | +|-------------|----------------------------------------------------------------------| +| 400 | Invalid request body or failed validation (e.g., missing required fields, mismatched counts) | +| 401 | Missing or invalid/expired auth token | +| 404 | Referenced resource not found (territory, island, book, etc.) | +| 409 | Rule violation (e.g., conflicting state) | +| 500 | Internal server error | \ No newline at end of file