lottery
This commit is contained in:
parent
ef105e1cac
commit
b918b92a4d
6 changed files with 702 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -77,6 +78,31 @@ type LeaderboardEntry struct {
|
|||
BalanceMinor int64
|
||||
}
|
||||
|
||||
type LotteryRoundView struct {
|
||||
ID string
|
||||
PoolMinor int64
|
||||
DrawAt time.Time
|
||||
ParticipantCount int
|
||||
TicketCount int
|
||||
UserTicketCount int
|
||||
UserContributionMinor int64
|
||||
}
|
||||
|
||||
type LotteryPayoutView struct {
|
||||
RoundID string
|
||||
WinnerUsername string
|
||||
WinnerAccount string
|
||||
PoolMinor int64
|
||||
ParticipantCount int
|
||||
TicketCount int
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type LotteryDashboard struct {
|
||||
ActiveRound *LotteryRoundView
|
||||
RecentPayouts []LotteryPayoutView
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
Action string
|
||||
TargetType string
|
||||
|
|
@ -95,6 +121,21 @@ type AdminDashboard struct {
|
|||
RecentAuditActivity []AuditLog
|
||||
}
|
||||
|
||||
type lotteryEntryRecord struct {
|
||||
UserID string
|
||||
AccountID string
|
||||
}
|
||||
|
||||
type lotterySettlementResult struct {
|
||||
RoundID string
|
||||
WinnerUsername string
|
||||
WinnerAccount string
|
||||
PoolMinor int64
|
||||
ParticipantCount int
|
||||
TicketCount int
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
|
@ -536,6 +577,210 @@ func (s *Store) CreateTransfer(ctx context.Context, senderUserID, destinationAcc
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetLotteryDashboard(ctx context.Context, userID string) (*LotteryDashboard, error) {
|
||||
var dashboard LotteryDashboard
|
||||
|
||||
var active LotteryRoundView
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select
|
||||
lr.id,
|
||||
lr.pool_minor,
|
||||
lr.draw_at,
|
||||
coalesce((select count(distinct le.user_id) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id and le.user_id = $1), 0),
|
||||
coalesce((select sum(le.amount_minor) from lottery_entries le where le.round_id = lr.id and le.user_id = $1), 0)
|
||||
from lottery_rounds lr
|
||||
where lr.status = 'active'
|
||||
order by lr.created_at desc
|
||||
limit 1
|
||||
`, userID).Scan(
|
||||
&active.ID,
|
||||
&active.PoolMinor,
|
||||
&active.DrawAt,
|
||||
&active.ParticipantCount,
|
||||
&active.TicketCount,
|
||||
&active.UserTicketCount,
|
||||
&active.UserContributionMinor,
|
||||
)
|
||||
if err == nil {
|
||||
dashboard.ActiveRound = &active
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("load active lottery round: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
select
|
||||
lr.id,
|
||||
u.username,
|
||||
a.account_number,
|
||||
lr.pool_minor,
|
||||
coalesce((select count(distinct le.user_id) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
lr.completed_at
|
||||
from lottery_rounds lr
|
||||
join users u on u.id = lr.winner_user_id
|
||||
join accounts a on a.id = lr.winner_account_id
|
||||
where lr.status = 'completed' and lr.winner_user_id is not null
|
||||
order by lr.completed_at desc
|
||||
limit 8
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lottery payouts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var item LotteryPayoutView
|
||||
if err := rows.Scan(
|
||||
&item.RoundID,
|
||||
&item.WinnerUsername,
|
||||
&item.WinnerAccount,
|
||||
&item.PoolMinor,
|
||||
&item.ParticipantCount,
|
||||
&item.TicketCount,
|
||||
&item.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan lottery payout: %w", err)
|
||||
}
|
||||
dashboard.RecentPayouts = append(dashboard.RecentPayouts, item)
|
||||
}
|
||||
|
||||
return &dashboard, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateLotteryDeposit(ctx context.Context, userID string, amountMinor int64) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin lottery deposit: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := s.settleDueLotteryRoundTx(ctx, tx, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type accountRecord struct {
|
||||
AccountID string
|
||||
UserStatus string
|
||||
Status string
|
||||
}
|
||||
|
||||
var account accountRecord
|
||||
err = tx.QueryRow(ctx, `
|
||||
select a.id, u.status, a.status
|
||||
from accounts a
|
||||
join users u on u.id = a.user_id
|
||||
where u.id = $1
|
||||
for update
|
||||
`, userID).Scan(&account.AccountID, &account.UserStatus, &account.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load lottery depositor account: %w", err)
|
||||
}
|
||||
if account.UserStatus != "active" || account.Status != "open" {
|
||||
return ErrFrozen
|
||||
}
|
||||
|
||||
balance, err := balanceForAccountTx(ctx, tx, account.AccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if balance < amountMinor {
|
||||
return ErrInsufficientFunds
|
||||
}
|
||||
|
||||
roundID, drawAt, err := s.findOrCreateActiveLotteryRoundTx(ctx, tx, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
transactionID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledgerEntryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lotteryEntryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("Lottery deposit for round closing at %s", drawAt.Format("15:04"))
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
|
||||
values ($1, 'lottery_deposit', 'completed', $2, $3, $4, now())
|
||||
`, transactionID, reference, userID, description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lottery deposit transaction: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into ledger_entries (id, transaction_id, account_id, entry_type, amount_minor, created_at)
|
||||
values ($1, $2, $3, 'debit', $4, now())
|
||||
`, ledgerEntryID, transactionID, account.AccountID, amountMinor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lottery deposit ledger entry: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into lottery_entries (id, round_id, user_id, account_id, amount_minor, created_at)
|
||||
values ($1, $2, $3, $4, $5, now())
|
||||
`, lotteryEntryID, roundID, userID, account.AccountID, amountMinor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lottery entry: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
update lottery_rounds
|
||||
set pool_minor = pool_minor + $2
|
||||
where id = $1
|
||||
`, roundID, amountMinor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update lottery round pool: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit lottery deposit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SettleDueLotteryRound(ctx context.Context) (*LotteryPayoutView, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin lottery settlement: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
result, err := s.settleDueLotteryRoundTx(ctx, tx, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit lottery settlement: %w", err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &LotteryPayoutView{
|
||||
RoundID: result.RoundID,
|
||||
WinnerUsername: result.WinnerUsername,
|
||||
WinnerAccount: result.WinnerAccount,
|
||||
PoolMinor: result.PoolMinor,
|
||||
ParticipantCount: result.ParticipantCount,
|
||||
TicketCount: result.TicketCount,
|
||||
CompletedAt: result.CompletedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AdminDashboard(ctx context.Context) (*AdminDashboard, error) {
|
||||
var dashboard AdminDashboard
|
||||
if err := s.pool.QueryRow(ctx, `select count(*) from users`).Scan(&dashboard.UserCount); err != nil {
|
||||
|
|
@ -821,6 +1066,171 @@ func (s *Store) insertAuditLog(ctx context.Context, adminID, action, targetType,
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) findOrCreateActiveLotteryRoundTx(ctx context.Context, tx pgx.Tx, now time.Time) (string, time.Time, error) {
|
||||
var roundID string
|
||||
var drawAt time.Time
|
||||
err := tx.QueryRow(ctx, `
|
||||
select id, draw_at
|
||||
from lottery_rounds
|
||||
where status = 'active'
|
||||
order by created_at desc
|
||||
limit 1
|
||||
for update
|
||||
`).Scan(&roundID, &drawAt)
|
||||
if err == nil {
|
||||
return roundID, drawAt, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", time.Time{}, fmt.Errorf("load active lottery round: %w", err)
|
||||
}
|
||||
|
||||
roundID, err = platform.NewID()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
drawAt = now.Add(time.Hour)
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into lottery_rounds (id, status, pool_minor, draw_at, created_at)
|
||||
values ($1, 'active', 0, $2, $3)
|
||||
`, roundID, drawAt, now)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("create lottery round: %w", err)
|
||||
}
|
||||
|
||||
return roundID, drawAt, nil
|
||||
}
|
||||
|
||||
func (s *Store) settleDueLotteryRoundTx(ctx context.Context, tx pgx.Tx, now time.Time) (*lotterySettlementResult, error) {
|
||||
var round struct {
|
||||
ID string
|
||||
PoolMinor int64
|
||||
DrawAt time.Time
|
||||
}
|
||||
|
||||
err := tx.QueryRow(ctx, `
|
||||
select id, pool_minor, draw_at
|
||||
from lottery_rounds
|
||||
where status = 'active'
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update
|
||||
`).Scan(&round.ID, &round.PoolMinor, &round.DrawAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load active lottery round for settlement: %w", err)
|
||||
}
|
||||
if round.DrawAt.After(now) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
select user_id, account_id
|
||||
from lottery_entries
|
||||
where round_id = $1
|
||||
order by created_at asc, id asc
|
||||
`, round.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load lottery entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []lotteryEntryRecord
|
||||
participants := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var entry lotteryEntryRecord
|
||||
if err := rows.Scan(&entry.UserID, &entry.AccountID); err != nil {
|
||||
return nil, fmt.Errorf("scan lottery entry: %w", err)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
participants[entry.UserID] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(entries) == 0 || round.PoolMinor == 0 {
|
||||
_, err := tx.Exec(ctx, `
|
||||
update lottery_rounds
|
||||
set status = 'completed', completed_at = $2
|
||||
where id = $1
|
||||
`, round.ID, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("complete empty lottery round: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
winnerIndex, err := cryptoRandomIndex(len(entries))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
winner := entries[winnerIndex]
|
||||
|
||||
var winnerUsername, winnerAccount string
|
||||
err = tx.QueryRow(ctx, `
|
||||
select u.username, a.account_number
|
||||
from users u
|
||||
join accounts a on a.id = $1
|
||||
where u.id = $2
|
||||
`, winner.AccountID, winner.UserID).Scan(&winnerUsername, &winnerAccount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load lottery winner: %w", err)
|
||||
}
|
||||
|
||||
transactionID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reference, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledgerEntryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
|
||||
values ($1, 'lottery_payout', 'completed', $2, $3, $4, $5)
|
||||
`, transactionID, reference, winner.UserID, fmt.Sprintf("Lottery payout from %d ticket(s)", len(entries)), now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert lottery payout transaction: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into ledger_entries (id, transaction_id, account_id, entry_type, amount_minor, created_at)
|
||||
values ($1, $2, $3, 'credit', $4, $5)
|
||||
`, ledgerEntryID, transactionID, winner.AccountID, round.PoolMinor, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert lottery payout ledger entry: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
update lottery_rounds
|
||||
set status = 'completed',
|
||||
winner_user_id = $2,
|
||||
winner_account_id = $3,
|
||||
completed_at = $4
|
||||
where id = $1
|
||||
`, round.ID, winner.UserID, winner.AccountID, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("complete lottery round: %w", err)
|
||||
}
|
||||
|
||||
return &lotterySettlementResult{
|
||||
RoundID: round.ID,
|
||||
WinnerUsername: winnerUsername,
|
||||
WinnerAccount: winnerAccount,
|
||||
PoolMinor: round.PoolMinor,
|
||||
ParticipantCount: len(participants),
|
||||
TicketCount: len(entries),
|
||||
CompletedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) generateUniqueAccountNumber(ctx context.Context, tx pgx.Tx) (string, error) {
|
||||
for i := 0; i < 20; i++ {
|
||||
candidate, err := randomAccountNumber()
|
||||
|
|
@ -853,6 +1263,19 @@ func randomAccountNumber() (string, error) {
|
|||
return fmt.Sprintf("%010d", n), nil
|
||||
}
|
||||
|
||||
func cryptoRandomIndex(limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
return 0, fmt.Errorf("limit must be positive")
|
||||
}
|
||||
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(limit)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read random index: %w", err)
|
||||
}
|
||||
|
||||
return int(n.Int64()), nil
|
||||
}
|
||||
|
||||
func balanceForAccountTx(ctx context.Context, tx pgx.Tx, accountID string) (int64, error) {
|
||||
var balance int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ type DashboardData struct {
|
|||
Transactions []db.TransactionView
|
||||
}
|
||||
|
||||
type LotteryPageData struct {
|
||||
User *db.User
|
||||
Lottery *db.LotteryDashboard
|
||||
LastPayout *db.LotteryPayoutView
|
||||
}
|
||||
|
||||
type AdminDashboardData struct {
|
||||
Admin *db.AdminUser
|
||||
Dashboard *db.AdminDashboard
|
||||
|
|
@ -158,6 +164,8 @@ func (a *App) routes() http.Handler {
|
|||
protected.Get("/app", a.userDashboard)
|
||||
protected.Get("/app/transfer", a.showTransfer)
|
||||
protected.Post("/app/transfer", a.createTransfer)
|
||||
protected.Get("/app/lottery", a.lotteryPage)
|
||||
protected.Post("/app/lottery/deposit", a.createLotteryDeposit)
|
||||
protected.Get("/app/transactions", a.transactionsPage)
|
||||
})
|
||||
|
||||
|
|
@ -387,6 +395,94 @@ func (a *App) transactionsPage(w http.ResponseWriter, r *http.Request) {
|
|||
a.render(w, http.StatusOK, ViewData{Title: "Transactions", Page: "transactions", User: user, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: DashboardData{User: user, Transactions: transactions}})
|
||||
}
|
||||
|
||||
func (a *App) lotteryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
lastPayout, err := a.store.SettleDueLotteryRound(r.Context())
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := a.store.GetUserDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
lottery, err := a.store.GetLotteryDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderLotteryPage(w, r, http.StatusOK, updatedUser, lottery, lastPayout, "")
|
||||
}
|
||||
|
||||
func (a *App) createLotteryDeposit(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
if !a.verifyCSRF(r) {
|
||||
a.renderLotteryError(w, r, user, http.StatusBadRequest, "Invalid form token.")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
amountMinor, err := platform.ParseCZK(r.FormValue("amount"))
|
||||
if err != nil || amountMinor <= 0 {
|
||||
a.renderLotteryError(w, r, user, http.StatusBadRequest, "Enter a valid positive CZK amount.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.store.CreateLotteryDeposit(r.Context(), user.ID, amountMinor); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrInsufficientFunds):
|
||||
a.renderLotteryError(w, r, user, http.StatusBadRequest, "Insufficient funds for this lottery deposit.")
|
||||
case errors.Is(err, db.ErrFrozen):
|
||||
a.renderLotteryError(w, r, user, http.StatusForbidden, "Lottery deposits are not available while the account is frozen.")
|
||||
default:
|
||||
a.renderLotteryError(w, r, user, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.setFlash(w, "Lottery deposit added to the active pool.")
|
||||
http.Redirect(w, r, "/app/lottery", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) renderLotteryPage(w http.ResponseWriter, r *http.Request, status int, user *db.User, lottery *db.LotteryDashboard, lastPayout *db.LotteryPayoutView, errMsg string) {
|
||||
a.render(w, status, ViewData{
|
||||
Title: "Lottery",
|
||||
Page: "lottery",
|
||||
User: user,
|
||||
CSRFToken: a.csrfToken(r),
|
||||
Flash: a.readFlash(w, r),
|
||||
Error: errMsg,
|
||||
Data: LotteryPageData{
|
||||
User: user,
|
||||
Lottery: lottery,
|
||||
LastPayout: lastPayout,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) renderLotteryError(w http.ResponseWriter, r *http.Request, user *db.User, status int, errMsg string) {
|
||||
lottery, err := a.store.GetLotteryDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := a.store.GetUserDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderLotteryPage(w, r, status, updatedUser, lottery, nil, errMsg)
|
||||
}
|
||||
|
||||
func (a *App) showAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, http.StatusOK, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
|
||||
}
|
||||
|
|
|
|||
23
migrations/002_lottery.sql
Normal file
23
migrations/002_lottery.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
create table if not exists lottery_rounds (
|
||||
id text primary key,
|
||||
status text not null,
|
||||
pool_minor bigint not null default 0 check (pool_minor >= 0),
|
||||
winner_user_id text references users(id),
|
||||
winner_account_id text references accounts(id),
|
||||
draw_at timestamptz not null,
|
||||
created_at timestamptz not null,
|
||||
completed_at timestamptz
|
||||
);
|
||||
|
||||
create table if not exists lottery_entries (
|
||||
id text primary key,
|
||||
round_id text not null references lottery_rounds(id) on delete cascade,
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
account_id text not null references accounts(id) on delete cascade,
|
||||
amount_minor bigint not null check (amount_minor > 0),
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create index if not exists idx_lottery_rounds_status_created_at on lottery_rounds(status, created_at desc);
|
||||
create index if not exists idx_lottery_entries_round_id on lottery_entries(round_id, created_at asc);
|
||||
create index if not exists idx_lottery_entries_user_id on lottery_entries(user_id, created_at desc);
|
||||
|
|
@ -258,6 +258,70 @@ h1 {
|
|||
min-width: min(100%, 360px);
|
||||
}
|
||||
|
||||
.lottery-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lottery-hero {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 209, 102, 0.32), transparent 35%),
|
||||
linear-gradient(140deg, #10214d 0%, #1540d1 52%, #1b8f6a 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.lottery-title {
|
||||
max-width: 12ch;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.lottery-hero .eyebrow,
|
||||
.lottery-hero .lede,
|
||||
.lottery-hero .meta-line {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.lottery-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.lottery-stat {
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.lottery-stat span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.lottery-stat strong {
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.lottery-banner {
|
||||
margin-top: 20px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.lottery-note {
|
||||
color: var(--muted);
|
||||
margin-bottom: 0;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.lottery-stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
|
@ -427,4 +491,9 @@ h1 {
|
|||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lottery-stat-row,
|
||||
.lottery-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<a href="/app">Dashboard</a>
|
||||
<a href="/leaderboard">Leaderboard</a>
|
||||
<a href="/app/transfer">Transfer</a>
|
||||
<a href="/app/lottery">Lottery</a>
|
||||
<a href="/app/transactions">Transactions</a>
|
||||
<form method="post" action="/logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
|
|
@ -61,6 +62,8 @@
|
|||
{{ template "leaderboard" . }}
|
||||
{{ else if eq .Page "transfer" }}
|
||||
{{ template "transfer" . }}
|
||||
{{ else if eq .Page "lottery" }}
|
||||
{{ template "lottery" . }}
|
||||
{{ else if eq .Page "transactions" }}
|
||||
{{ template "transactions" . }}
|
||||
{{ else if eq .Page "admin_dashboard" }}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,94 @@
|
|||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "lottery" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="stack-section">
|
||||
<div class="page-grid lottery-grid">
|
||||
<div class="card lottery-hero">
|
||||
<p class="eyebrow">Hourly pool</p>
|
||||
<h1 class="lottery-title">Deposit into the pool and wait for the next draw.</h1>
|
||||
<p class="lede">Each deposit joins the active round. When the draw deadline passes, the full pool is credited to one randomly selected ticket holder.</p>
|
||||
<div class="lottery-stat-row">
|
||||
<article class="lottery-stat">
|
||||
<span>Your balance</span>
|
||||
<strong>{{ money $page.User.BalanceMinor }}</strong>
|
||||
</article>
|
||||
<article class="lottery-stat">
|
||||
<span>Current pool</span>
|
||||
<strong>{{ if $page.Lottery.ActiveRound }}{{ money $page.Lottery.ActiveRound.PoolMinor }}{{ else }}0,00 CZK{{ end }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
{{ if $page.LastPayout }}
|
||||
<div class="lottery-banner">
|
||||
Latest draw paid <strong>{{ money $page.LastPayout.PoolMinor }}</strong> to <strong>{{ $page.LastPayout.WinnerUsername }}</strong>.
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Join the round</h2>
|
||||
<form method="post" action="/app/lottery/deposit" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<label>Amount in CZK<input type="text" name="amount" placeholder="100,00" required></label>
|
||||
<button class="button primary" type="submit">Deposit to lottery</button>
|
||||
</form>
|
||||
<p class="muted lottery-note">Each deposit creates one ticket. More deposits mean more chances in the same hourly round.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-grid lottery-grid">
|
||||
<div class="card">
|
||||
<h2>Active round</h2>
|
||||
{{ if $page.Lottery.ActiveRound }}
|
||||
<dl class="stats-grid lottery-stats-grid">
|
||||
<div>
|
||||
<dt>Draw at</dt>
|
||||
<dd>{{ dt $page.Lottery.ActiveRound.DrawAt }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Participants</dt>
|
||||
<dd>{{ $page.Lottery.ActiveRound.ParticipantCount }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Total tickets</dt>
|
||||
<dd>{{ $page.Lottery.ActiveRound.TicketCount }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Your tickets</dt>
|
||||
<dd>{{ $page.Lottery.ActiveRound.UserTicketCount }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="meta-line">Your current contribution: <strong>{{ money $page.Lottery.ActiveRound.UserContributionMinor }}</strong></p>
|
||||
{{ else }}
|
||||
<p class="muted">No active round yet. The first deposit starts the next one-hour lottery window.</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Recent winners</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Winner</th><th>Pool</th><th>Tickets</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range $page.Lottery.RecentPayouts }}
|
||||
<tr>
|
||||
<td>{{ dt .CompletedAt }}</td>
|
||||
<td>{{ .WinnerUsername }}<div class="subtle">{{ .WinnerAccount }}</div></td>
|
||||
<td>{{ money .PoolMinor }}</td>
|
||||
<td>{{ .TicketCount }}</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="4" class="muted">No lottery winners yet.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_dashboard" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="stack-section">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue