package session import ( "context" "database/sql" "errors" "fmt" "time" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) // Session lifecycle statuses persisted in the status column. const ( StatusActive = "active" StatusRevoked = "revoked" ) // ErrNotFound is returned when no active session matches the lookup. var ErrNotFound = errors.New("session: not found") // Session mirrors a row in backend.sessions. TokenHash is the hex-encoded // SHA-256 of the bearer token. type Session struct { ID uuid.UUID AccountID uuid.UUID TokenHash string Status string CreatedAt time.Time LastSeenAt *time.Time RevokedAt *time.Time } // Store is the Postgres-backed query surface for backend.sessions. type Store struct { db *sql.DB } // NewStore constructs a Store wrapping db. func NewStore(db *sql.DB) *Store { return &Store{db: db} } // Insert persists a new active session for accountID carrying tokenHash and // returns the persisted row. func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) { id, err := uuid.NewV7() if err != nil { return Session{}, fmt.Errorf("session: new id: %w", err) } stmt := table.Sessions.INSERT( table.Sessions.SessionID, table.Sessions.AccountID, table.Sessions.TokenHash, ).VALUES(id, accountID, tokenHash).RETURNING(table.Sessions.AllColumns) var row model.Sessions if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return Session{}, fmt.Errorf("session: insert: %w", err) } return modelToSession(row), nil } // FindActiveByTokenHash returns the active session matching tokenHash, or // ErrNotFound. func (s *Store) FindActiveByTokenHash(ctx context.Context, tokenHash string) (Session, error) { stmt := postgres.SELECT(table.Sessions.AllColumns). FROM(table.Sessions). WHERE( table.Sessions.TokenHash.EQ(postgres.String(tokenHash)). AND(table.Sessions.Status.EQ(postgres.String(StatusActive))), ). LIMIT(1) var row model.Sessions if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Session{}, ErrNotFound } return Session{}, fmt.Errorf("session: find by token hash: %w", err) } return modelToSession(row), nil } // RevokeByTokenHash transitions the active session for tokenHash to revoked and // returns the post-update row. ok is false with a nil error when no active // session matched, so revocation is idempotent. func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time.Time) (Session, bool, error) { stmt := table.Sessions. UPDATE(table.Sessions.Status, table.Sessions.RevokedAt). SET(postgres.String(StatusRevoked), postgres.TimestampzT(at)). WHERE( table.Sessions.TokenHash.EQ(postgres.String(tokenHash)). AND(table.Sessions.Status.EQ(postgres.String(StatusActive))), ). RETURNING(table.Sessions.AllColumns) var row model.Sessions if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Session{}, false, nil } return Session{}, false, fmt.Errorf("session: revoke by token hash: %w", err) } return modelToSession(row), true, nil } // ListActive loads every active session. Cache.Warm calls this at boot. func (s *Store) ListActive(ctx context.Context) ([]Session, error) { stmt := postgres.SELECT(table.Sessions.AllColumns). FROM(table.Sessions). WHERE(table.Sessions.Status.EQ(postgres.String(StatusActive))) var rows []model.Sessions if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("session: list active: %w", err) } out := make([]Session, 0, len(rows)) for _, row := range rows { out = append(out, modelToSession(row)) } return out, nil } // modelToSession projects a generated model row into the public Session struct, // copying pointer fields so callers cannot mutate the scan buffer. func modelToSession(row model.Sessions) Session { s := Session{ ID: row.SessionID, AccountID: row.AccountID, TokenHash: row.TokenHash, Status: row.Status, CreatedAt: row.CreatedAt, } if row.LastSeenAt != nil { t := *row.LastSeenAt s.LastSeenAt = &t } if row.RevokedAt != nil { t := *row.RevokedAt s.RevokedAt = &t } return s }