Add user authentication and game persistence with SQLite

- User registration/login with bcrypt password hashing
- SQLite database with goose migrations and sqlc-generated queries
- Games and players persisted to database, resumable after restart
- Guest play still supported alongside authenticated users
- Auth UI components (login/register forms, auth header, guest banner)
This commit is contained in:
Ryan Hamamura
2026-01-14 16:59:40 -10:00
parent 03dcfdbf85
commit b264d8990b
18 changed files with 1121 additions and 5 deletions

31
db/gen/db.go Normal file
View File

@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package gen
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

237
db/gen/games.sql.go Normal file
View File

@@ -0,0 +1,237 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: games.sql
package gen
import (
"context"
"database/sql"
)
const createGame = `-- name: CreateGame :one
INSERT INTO games (id, board, current_turn, status)
VALUES (?, ?, ?, ?)
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at
`
type CreateGameParams struct {
ID string
Board string
CurrentTurn int64
Status int64
}
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
row := q.db.QueryRowContext(ctx, createGame,
arg.ID,
arg.Board,
arg.CurrentTurn,
arg.Status,
)
var i Game
err := row.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createGamePlayer = `-- name: CreateGamePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
VALUES (?, ?, ?, ?, ?, ?)
`
type CreateGamePlayerParams struct {
GameID string
UserID sql.NullString
GuestPlayerID sql.NullString
Nickname string
Color int64
Slot int64
}
func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error {
_, err := q.db.ExecContext(ctx, createGamePlayer,
arg.GameID,
arg.UserID,
arg.GuestPlayerID,
arg.Nickname,
arg.Color,
arg.Slot,
)
return err
}
const deleteGame = `-- name: DeleteGame :exec
DELETE FROM games WHERE id = ?
`
func (q *Queries) DeleteGame(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteGame, id)
return err
}
const getActiveGames = `-- name: GetActiveGames :many
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE status < 2
`
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
rows, err := q.db.QueryContext(ctx, getActiveGames)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Game
for rows.Next() {
var i Game
if err := rows.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGame = `-- name: GetGame :one
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE id = ?
`
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
row := q.db.QueryRowContext(ctx, getGame, id)
var i Game
err := row.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getGamePlayers = `-- name: GetGamePlayers :many
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ?
`
func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GamePlayer
for rows.Next() {
var i GamePlayer
if err := rows.Scan(
&i.GameID,
&i.UserID,
&i.GuestPlayerID,
&i.Nickname,
&i.Color,
&i.Slot,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGamesByUserID = `-- name: GetGamesByUserID :many
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at FROM games g
JOIN game_players gp ON g.id = gp.game_id
WHERE gp.user_id = ?
ORDER BY g.updated_at DESC
`
func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ([]Game, error) {
rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Game
for rows.Next() {
var i Game
if err := rows.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateGame = `-- name: UpdateGame :exec
UPDATE games
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type UpdateGameParams struct {
Board string
CurrentTurn int64
Status int64
WinnerUserID sql.NullString
WinningCells sql.NullString
ID string
}
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
_, err := q.db.ExecContext(ctx, updateGame,
arg.Board,
arg.CurrentTurn,
arg.Status,
arg.WinnerUserID,
arg.WinningCells,
arg.ID,
)
return err
}

37
db/gen/models.go Normal file
View File

@@ -0,0 +1,37 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package gen
import (
"database/sql"
)
type Game struct {
ID string
Board string
CurrentTurn int64
Status int64
WinnerUserID sql.NullString
WinningCells sql.NullString
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
}
type GamePlayer struct {
GameID string
UserID sql.NullString
GuestPlayerID sql.NullString
Nickname string
Color int64
Slot int64
CreatedAt sql.NullTime
}
type User struct {
ID string
Username string
PasswordHash string
CreatedAt sql.NullTime
}

66
db/gen/users.sql.go Normal file
View File

@@ -0,0 +1,66 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: users.sql
package gen
import (
"context"
)
const createUser = `-- name: CreateUser :one
INSERT INTO users (id, username, password_hash)
VALUES (?, ?, ?)
RETURNING id, username, password_hash, created_at
`
type CreateUserParams struct {
ID string
Username string
PasswordHash string
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, created_at FROM users WHERE id = ?
`
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at FROM users WHERE username = ?
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}