feat: initial commit
This commit is contained in:
@ -9,8 +9,8 @@ type Config struct {
|
||||
Logger Logger `envPrefix:"LOGGER_"`
|
||||
Auth Auth `envPrefix:"AUTH_"`
|
||||
HTTP HTTP `envPrefix:"HTTP_"`
|
||||
LLM LLM `envPrefix:"LLM_"`
|
||||
Store Store `envPrefix:"STORE_"`
|
||||
Quiz Quiz `envPrefix:"QUIZ_"`
|
||||
}
|
||||
|
||||
func Parse() (*Config, error) {
|
||||
|
@ -1,12 +0,0 @@
|
||||
package config
|
||||
|
||||
type LLM struct {
|
||||
Provider LLMProvider `envPrefix:"PROVIDER_"`
|
||||
}
|
||||
|
||||
type LLMProvider struct {
|
||||
Name string `env:"NAME" envDefault:"openai"`
|
||||
BaseURL string `env:"BASE_URL" envDefault:"https://api.openai.com/v1/"`
|
||||
Key string `env:"KEY"`
|
||||
Model string `env:"MODEL" envDefault:"gpt-4o-mini"`
|
||||
}
|
8
internal/config/quiz.go
Normal file
8
internal/config/quiz.go
Normal file
@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type Quiz struct {
|
||||
Language string `env:"LANGUAGE" envDefault:"fr"`
|
||||
PlayInterval time.Duration `env:"PLAY_INTERVAL" envDefault:"2h"`
|
||||
}
|
@ -35,6 +35,14 @@ func (h *Handler) handleProviderCallback(w http.ResponseWriter, r *http.Request)
|
||||
Provider: gothUser.Provider,
|
||||
AccessToken: gothUser.AccessToken,
|
||||
IDToken: gothUser.IDToken,
|
||||
Name: gothUser.Name,
|
||||
}
|
||||
|
||||
rawPreferredUsername, exists := gothUser.RawData["preferred_username"]
|
||||
if exists {
|
||||
if preferredUsername, ok := rawPreferredUsername.(string); ok {
|
||||
user.Name = preferredUsername
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.storeSessionUser(w, r, user); err != nil {
|
||||
|
@ -5,4 +5,5 @@ type User struct {
|
||||
Provider string
|
||||
AccessToken string
|
||||
IDToken string
|
||||
Name string
|
||||
}
|
||||
|
@ -5,12 +5,14 @@ import (
|
||||
"context"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/http/form"
|
||||
common "forge.cadoles.com/wpetit/kouiz/internal/http/handler/webui/common/component"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/yuin/goldmark"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type QuizPageVModel struct {
|
||||
Player *store.Player
|
||||
}
|
||||
|
||||
func NewQuizForm() *form.Form {
|
||||
|
@ -13,12 +13,14 @@ import (
|
||||
"context"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/http/form"
|
||||
common "forge.cadoles.com/wpetit/kouiz/internal/http/handler/webui/common/component"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/yuin/goldmark"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type QuizPageVModel struct {
|
||||
Player *store.Player
|
||||
}
|
||||
|
||||
func NewQuizForm() *form.Form {
|
||||
|
@ -2,13 +2,15 @@ package quiz
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/store"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
mux *http.ServeMux
|
||||
store *store.Store
|
||||
mux *http.ServeMux
|
||||
store *store.Store
|
||||
playInterval time.Duration
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
@ -16,10 +18,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func NewHandler(store *store.Store) *Handler {
|
||||
func NewHandler(store *store.Store, playInterval time.Duration) *Handler {
|
||||
h := &Handler{
|
||||
mux: http.NewServeMux(),
|
||||
store: store,
|
||||
mux: http.NewServeMux(),
|
||||
store: store,
|
||||
playInterval: playInterval,
|
||||
}
|
||||
|
||||
h.mux.HandleFunc("GET /", h.getQuizPage)
|
||||
|
@ -1,11 +1,15 @@
|
||||
package quiz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/http/handler/webui/auth"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/http/handler/webui/common"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/http/handler/webui/quiz/component"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/store"
|
||||
"github.com/a-h/templ"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@ -25,6 +29,8 @@ func (h *Handler) fillQuizPageVModel(r *http.Request) (*component.QuizPageVModel
|
||||
|
||||
err := common.FillViewModel(
|
||||
r.Context(), vmodel, r,
|
||||
h.fillQuizPagePlayer,
|
||||
h.fillQuizPageTurn,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
@ -33,6 +39,35 @@ func (h *Handler) fillQuizPageVModel(r *http.Request) (*component.QuizPageVModel
|
||||
return vmodel, nil
|
||||
}
|
||||
|
||||
func (h *Handler) fillQuizPagePlayer(ctx context.Context, vmodel *component.QuizPageVModel, r *http.Request) error {
|
||||
user := auth.ContextUser(ctx)
|
||||
|
||||
player := &store.Player{
|
||||
Name: user.Name,
|
||||
UserID: user.ID,
|
||||
UserProvider: user.Provider,
|
||||
}
|
||||
|
||||
if err := h.store.UpsertPlayer(ctx, player); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
vmodel.Player = player
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) fillQuizPageTurn(ctx context.Context, vmodel *component.QuizPageVModel, r *http.Request) error {
|
||||
turn, err := h.store.GetQuizTurn(ctx, h.playInterval)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
spew.Dump(turn)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleQuizForm(w http.ResponseWriter, r *http.Request) {
|
||||
quizForm := component.NewQuizForm()
|
||||
|
||||
|
@ -2,25 +2,90 @@ package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/config"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/http/handler/webui/quiz"
|
||||
"forge.cadoles.com/wpetit/kouiz/internal/store"
|
||||
"forge.cadoles.com/wpetit/kouiz/misc/quiz/openquizzdb"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewQuizHandlerFromConfig(ctx context.Context, conf *config.Config) (*quiz.Handler, error) {
|
||||
db, err := gorm.Open(sqlite.Open(string(conf.Store.DSN)), &gorm.Config{})
|
||||
db, err := gorm.Open(sqlite.Open(string(conf.Store.DSN)), &gorm.Config{
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] %+v", errors.Wrap(err, "could not open store"))
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if conf.Store.Debug {
|
||||
db = db.Debug()
|
||||
}
|
||||
|
||||
return quiz.NewHandler(store.New(db)), nil
|
||||
store := store.New(db)
|
||||
|
||||
if err := loadEmbeddedQuizz(ctx, store); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return quiz.NewHandler(store, conf.Quiz.PlayInterval), nil
|
||||
}
|
||||
|
||||
func loadEmbeddedQuizz(ctx context.Context, st *store.Store) error {
|
||||
quizz, err := openquizzdb.LoadAll()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
for _, q := range quizz {
|
||||
c := q.Categories["fr"]
|
||||
|
||||
category := &store.QuizCategory{
|
||||
Name: c.Name,
|
||||
Theme: c.Label,
|
||||
Description: c.Slogan,
|
||||
}
|
||||
|
||||
err := st.UpsertQuizCategory(ctx, category)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
quiz := q.Quizz["fr"]
|
||||
levels := [][]openquizzdb.Entry{
|
||||
quiz.Beginner,
|
||||
quiz.Intermediate,
|
||||
quiz.Expert,
|
||||
}
|
||||
|
||||
for i, l := range levels {
|
||||
for _, e := range l {
|
||||
entry := &store.QuizEntry{
|
||||
CategoryID: category.ID,
|
||||
Question: e.Question,
|
||||
Level: uint(i),
|
||||
Provider: "openquizzdb",
|
||||
ProviderID: fmt.Sprintf("%d-%d-%d", category.ID, i, e.ID),
|
||||
Propositions: datatypes.NewJSONSlice(e.Propositions),
|
||||
Answer: e.Answer,
|
||||
Anecdote: e.Anecdote,
|
||||
}
|
||||
|
||||
err := st.UpsertQuizEntry(ctx, entry)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1,15 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var models = []any{
|
||||
&Player{},
|
||||
&QuizCategory{},
|
||||
&QuizEntry{},
|
||||
&QuizTurn{},
|
||||
}
|
||||
|
||||
type Player struct {
|
||||
gorm.Model
|
||||
|
||||
Name string
|
||||
|
||||
UserID string `gorm:"index"`
|
||||
UserProvider string `gorm:"index"`
|
||||
|
||||
Score int
|
||||
|
||||
PlayedAt time.Time
|
||||
}
|
||||
|
||||
type QuizTurn struct {
|
||||
gorm.Model
|
||||
|
||||
StartedAt time.Time `gorm:"index"`
|
||||
EndedAt time.Time `gorm:"index"`
|
||||
|
||||
Entries []*QuizEntry `gorm:"many2many:quiz_turn_entries;"`
|
||||
}
|
||||
|
||||
type QuizCategory struct {
|
||||
gorm.Model
|
||||
|
||||
Name string `gorm:"index"`
|
||||
Theme string `gorm:"index"`
|
||||
Description string
|
||||
}
|
||||
|
||||
type QuizEntry struct {
|
||||
gorm.Model
|
||||
|
||||
Category *QuizCategory
|
||||
CategoryID uint
|
||||
|
||||
Provider string `gorm:"index"`
|
||||
ProviderID string `gorm:"index"`
|
||||
|
||||
Question string
|
||||
Propositions datatypes.JSONSlice[string]
|
||||
Answer string
|
||||
Level uint `gorm:"index"`
|
||||
Anecdote string
|
||||
}
|
||||
|
28
internal/store/player.go
Normal file
28
internal/store/player.go
Normal file
@ -0,0 +1,28 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Store) UpsertPlayer(ctx context.Context, player *Player) error {
|
||||
return errors.WithStack(s.Do(ctx, func(db *gorm.DB) error {
|
||||
var existing *Player
|
||||
err := db.Find(&existing, "user_id = ? and user_provider = ?", player.UserID, player.UserProvider).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
player.Model = existing.Model
|
||||
}
|
||||
|
||||
if err := db.Save(player).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
114
internal/store/quiz.go
Normal file
114
internal/store/quiz.go
Normal file
@ -0,0 +1,114 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Store) UpsertQuizCategory(ctx context.Context, category *QuizCategory) error {
|
||||
return errors.WithStack(s.Do(ctx, func(db *gorm.DB) error {
|
||||
var existing *QuizCategory
|
||||
err := db.Find(&existing, "name = ? and theme = ?", category.Name, category.Theme).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
category.Model = existing.Model
|
||||
}
|
||||
|
||||
if err := db.Save(category).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Store) UpsertQuizEntry(ctx context.Context, entry *QuizEntry) error {
|
||||
return errors.WithStack(s.Do(ctx, func(db *gorm.DB) error {
|
||||
var existing *QuizEntry
|
||||
err := db.Find(&existing, "provider = ? and provider_id = ?", entry.Provider, entry.ProviderID).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
entry.Model = existing.Model
|
||||
}
|
||||
|
||||
if err := db.Save(entry).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Store) GetQuizTurn(ctx context.Context, playInterval time.Duration) (*QuizTurn, error) {
|
||||
var quizTurn *QuizTurn
|
||||
err := s.Tx(ctx, func(tx *gorm.DB) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
err := tx.Model(&quizTurn).Preload("Entries").Preload("Entries.Category").Order("ended_at DESC").First(&quizTurn, "ended_at >= ?", now).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if quizTurn != nil && quizTurn.ID != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
quizTurn = &QuizTurn{
|
||||
StartedAt: now.Round(time.Hour),
|
||||
EndedAt: now.Add(playInterval).Round(time.Hour),
|
||||
}
|
||||
|
||||
alreadyUsed := make([]uint, 0)
|
||||
err = tx.Table("quiz_turn_entries").Pluck("quiz_entry_id", &alreadyUsed).Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
query := tx.Model(&QuizEntry{})
|
||||
|
||||
if len(alreadyUsed) > 0 {
|
||||
query = query.Where("id NOT IN ?", alreadyUsed)
|
||||
}
|
||||
|
||||
var entryIDs []uint
|
||||
err = query.Pluck("id", &entryIDs).Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
for range 3 {
|
||||
index := rand.IntN(len(entryIDs))
|
||||
quizTurn.Entries = append(quizTurn.Entries, &QuizEntry{
|
||||
Model: gorm.Model{
|
||||
ID: entryIDs[index],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Save(quizTurn).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = tx.Model(&QuizTurn{}).Preload("Entries").Preload("Entries.Category").First(&quizTurn).Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return quizTurn, nil
|
||||
}
|
@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@ -31,6 +32,21 @@ func (s *Store) Do(ctx context.Context, fn func(db *gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Tx(ctx context.Context, fn func(db *gorm.DB) error, opts ...*sql.TxOptions) error {
|
||||
return errors.WithStack(s.Do(ctx, func(db *gorm.DB) error {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := fn(tx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
return nil
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func createGetDatabase(db *gorm.DB) func(ctx context.Context) (*gorm.DB, error) {
|
||||
var (
|
||||
migrateOnce sync.Once
|
||||
|
27
internal/store/types.go
Normal file
27
internal/store/types.go
Normal file
@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StringSlice []string
|
||||
|
||||
func (s StringSlice) Scan(src any) error {
|
||||
bytes, ok := src.([]byte)
|
||||
if !ok {
|
||||
return errors.New("src value cannot cast to []byte")
|
||||
}
|
||||
|
||||
s = strings.Split(string(bytes), "|")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if len(s) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return strings.Join(s, "|"), nil
|
||||
}
|
Reference in New Issue
Block a user