init
This commit is contained in:
commit
ef105e1cac
22 changed files with 3001 additions and 0 deletions
6
.env.example
Normal file
6
.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
HTTP_ADDR=:8080
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5433/superbanka?sslmode=disable
|
||||
SESSION_KEY=dev-session-key-change-me
|
||||
APP_ENV=development
|
||||
ADMIN_EMAIL=admin@superbanka.local
|
||||
ADMIN_PASSWORD=admin123456
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.DS_Store
|
||||
.env
|
||||
tmp/
|
||||
bin/
|
||||
21
Makefile
Normal file
21
Makefile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
APP_NAME=superbanka
|
||||
|
||||
.PHONY: dev up down tidy test migrate
|
||||
|
||||
dev:
|
||||
go run ./cmd/web
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
migrate:
|
||||
go run ./cmd/web
|
||||
19
README.md
Normal file
19
README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Superbanka
|
||||
|
||||
Server-rendered Go banking demo with PostgreSQL, internal account-number transfers, separate admin accounts, and ledger-backed balances.
|
||||
|
||||
## Local development
|
||||
|
||||
1. Create local config: `cp .env.example .env`
|
||||
2. Start PostgreSQL: `make up`
|
||||
3. Run the app: `make dev`
|
||||
|
||||
The app listens on `http://localhost:8080` by default.
|
||||
Postgres is exposed on `localhost:5433` to avoid conflicts with any existing local instance.
|
||||
|
||||
The app loads `.env` automatically on startup. Existing shell environment variables still take precedence over values in `.env`.
|
||||
|
||||
Default bootstrap admin credentials:
|
||||
|
||||
- Email: `admin@superbanka.local`
|
||||
- Password: `admin123456`
|
||||
49
cmd/web/main.go
Normal file
49
cmd/web/main.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"superbanka/internal/config"
|
||||
apphttp "superbanka/internal/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
app, err := apphttp.NewApp(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("create app: %v", err)
|
||||
}
|
||||
defer app.Close()
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: app.Router(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("listening on %s", cfg.HTTPAddr)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown http server: %v", err)
|
||||
}
|
||||
}
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: superbanka
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module superbanka
|
||||
|
||||
go 1.25.6
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
30
go.sum
Normal file
30
go.sum
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
16
internal/auth/passwords.go
Normal file
16
internal/auth/passwords.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
11
internal/auth/tokens.go
Normal file
11
internal/auth/tokens.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
83
internal/config/config.go
Normal file
83
internal/config/config.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
SessionKey string
|
||||
AppEnv string
|
||||
AdminEmail string
|
||||
AdminPass string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
if err := loadDotEnv(".env"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: load .env: %v\n", err)
|
||||
}
|
||||
|
||||
return Config{
|
||||
HTTPAddr: getenv("HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5433/superbanka?sslmode=disable"),
|
||||
SessionKey: getenv("SESSION_KEY", "dev-session-key-change-me"),
|
||||
AppEnv: getenv("APP_ENV", "development"),
|
||||
AdminEmail: getenv("ADMIN_EMAIL", "admin@superbanka.local"),
|
||||
AdminPass: getenv("ADMIN_PASSWORD", "admin123456"),
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func loadDotEnv(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("line %d: missing '='", lineNumber)
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Trim(value, `"'`)
|
||||
if key == "" {
|
||||
return fmt.Errorf("line %d: empty key", lineNumber)
|
||||
}
|
||||
|
||||
if _, exists := os.LookupEnv(key); exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("line %d: set %s: %w", lineNumber, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
72
internal/db/migrate.go
Normal file
72
internal/db/migrate.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, dir string) error {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
create table if not exists schema_migrations (
|
||||
filename text primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, name := range files {
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `select exists(select 1 from schema_migrations where filename = $1)`, name).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, string(body)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("execute migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `insert into schema_migrations (filename) values ($1)`, name); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
872
internal/db/store.go
Normal file
872
internal/db/store.go
Normal file
|
|
@ -0,0 +1,872 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"superbanka/internal/platform"
|
||||
)
|
||||
|
||||
const signupBonusMinor int64 = 1000000
|
||||
|
||||
var ErrConflict = errors.New("conflict")
|
||||
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
var ErrInsufficientFunds = errors.New("insufficient funds")
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var ErrFrozen = errors.New("account frozen")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Username string
|
||||
Status string
|
||||
AccountID string
|
||||
AccountNumber string
|
||||
BalanceMinor int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type TransactionView struct {
|
||||
ID string
|
||||
Type string
|
||||
Description string
|
||||
Direction string
|
||||
SenderAccount string
|
||||
RecipientAccount string
|
||||
AmountMinor int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type UserListItem struct {
|
||||
ID string
|
||||
Email string
|
||||
Username string
|
||||
Status string
|
||||
AccountNumber string
|
||||
BalanceMinor int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type LeaderboardEntry struct {
|
||||
Rank int
|
||||
Username string
|
||||
AccountNumber string
|
||||
BalanceMinor int64
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Metadata string
|
||||
CreatedAt time.Time
|
||||
AdminEmail string
|
||||
}
|
||||
|
||||
type AdminDashboard struct {
|
||||
UserCount int
|
||||
AccountCount int
|
||||
TransactionCount int
|
||||
FrozenUserCount int
|
||||
RecentTransactions []TransactionView
|
||||
RecentAuditActivity []AuditLog
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
func (s *Store) BootstrapAdmin(ctx context.Context, email, passwordHash string) error {
|
||||
var exists bool
|
||||
if err := s.pool.QueryRow(ctx, `select exists(select 1 from admin_users)`).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check existing admins: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
insert into admin_users (id, email, password_hash, role, status, created_at, updated_at)
|
||||
values ($1, $2, $3, 'super_admin', 'active', now(), now())
|
||||
`, id, strings.ToLower(strings.TrimSpace(email)), passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert bootstrap admin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, email, username, passwordHash string) (*User, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin create user: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
userID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transactionID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reference, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountNumber, err := s.generateUniqueAccountNumber(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into users (id, email, username, password_hash, status, created_at, updated_at)
|
||||
values ($1, $2, $3, $4, 'active', now(), now())
|
||||
`, userID, strings.ToLower(strings.TrimSpace(email)), strings.ToLower(strings.TrimSpace(username)), passwordHash)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into accounts (id, user_id, account_number, currency, status, created_at)
|
||||
values ($1, $2, $3, 'CZK', 'open', now())
|
||||
`, accountID, userID, accountNumber)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert account: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into transactions (id, type, status, reference, description, created_at)
|
||||
values ($1, 'signup_bonus', 'completed', $2, 'Signup bonus', now())
|
||||
`, transactionID, reference)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert signup 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, now())
|
||||
`, entryID, transactionID, accountID, signupBonusMinor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert signup ledger entry: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit create user: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUserDashboard(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, string, error) {
|
||||
var user User
|
||||
var passwordHash string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select u.id, u.email, u.username, u.status, a.id, a.account_number, u.created_at, u.password_hash
|
||||
from users u
|
||||
join accounts a on a.user_id = u.id
|
||||
where u.email = $1
|
||||
`, strings.ToLower(strings.TrimSpace(email))).Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.Username,
|
||||
&user.Status,
|
||||
&user.AccountID,
|
||||
&user.AccountNumber,
|
||||
&user.CreatedAt,
|
||||
&passwordHash,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get user by email: %w", err)
|
||||
}
|
||||
|
||||
balance, err := s.BalanceForAccount(ctx, user.AccountID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
user.BalanceMinor = balance
|
||||
|
||||
return &user, passwordHash, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminByEmail(ctx context.Context, email string) (*AdminUser, string, error) {
|
||||
var admin AdminUser
|
||||
var passwordHash string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select id, email, role, status, created_at, password_hash
|
||||
from admin_users
|
||||
where email = $1
|
||||
`, strings.ToLower(strings.TrimSpace(email))).Scan(&admin.ID, &admin.Email, &admin.Role, &admin.Status, &admin.CreatedAt, &passwordHash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get admin by email: %w", err)
|
||||
}
|
||||
|
||||
return &admin, passwordHash, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUserSession(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||
id, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
insert into user_sessions (id, user_id, token_hash, expires_at, created_at)
|
||||
values ($1, $2, $3, $4, now())
|
||||
`, id, userID, tokenHash, expiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAdminSession(ctx context.Context, adminID, tokenHash string, expiresAt time.Time) error {
|
||||
id, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
insert into admin_sessions (id, admin_user_id, token_hash, expires_at, created_at)
|
||||
values ($1, $2, $3, $4, now())
|
||||
`, id, adminID, tokenHash, expiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create admin session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserSession(ctx context.Context, tokenHash string) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from user_sessions where token_hash = $1`, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAdminSession(ctx context.Context, tokenHash string) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from admin_sessions where token_hash = $1`, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete admin session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UserFromSession(ctx context.Context, tokenHash string) (*User, error) {
|
||||
var user User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select u.id, u.email, u.username, u.status, a.id, a.account_number, u.created_at
|
||||
from user_sessions us
|
||||
join users u on u.id = us.user_id
|
||||
join accounts a on a.user_id = u.id
|
||||
where us.token_hash = $1 and us.expires_at > now()
|
||||
`, tokenHash).Scan(&user.ID, &user.Email, &user.Username, &user.Status, &user.AccountID, &user.AccountNumber, &user.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user from session: %w", err)
|
||||
}
|
||||
|
||||
balance, err := s.BalanceForAccount(ctx, user.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.BalanceMinor = balance
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *Store) AdminFromSession(ctx context.Context, tokenHash string) (*AdminUser, error) {
|
||||
var admin AdminUser
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select a.id, a.email, a.role, a.status, a.created_at
|
||||
from admin_sessions s
|
||||
join admin_users a on a.id = s.admin_user_id
|
||||
where s.token_hash = $1 and s.expires_at > now()
|
||||
`, tokenHash).Scan(&admin.ID, &admin.Email, &admin.Role, &admin.Status, &admin.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin from session: %w", err)
|
||||
}
|
||||
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserDashboard(ctx context.Context, userID string) (*User, error) {
|
||||
var user User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select u.id, u.email, u.username, u.status, a.id, a.account_number, u.created_at
|
||||
from users u
|
||||
join accounts a on a.user_id = u.id
|
||||
where u.id = $1
|
||||
`, userID).Scan(&user.ID, &user.Email, &user.Username, &user.Status, &user.AccountID, &user.AccountNumber, &user.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user dashboard: %w", err)
|
||||
}
|
||||
|
||||
balance, err := s.BalanceForAccount(ctx, user.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.BalanceMinor = balance
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *Store) BalanceForAccount(ctx context.Context, accountID string) (int64, error) {
|
||||
var balance int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select coalesce(sum(case when entry_type = 'credit' then amount_minor else -amount_minor end), 0)
|
||||
from ledger_entries
|
||||
where account_id = $1
|
||||
`, accountID).Scan(&balance)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("balance for account: %w", err)
|
||||
}
|
||||
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (s *Store) RecentTransactionsForAccount(ctx context.Context, accountID string, limit int) ([]TransactionView, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
select
|
||||
t.id,
|
||||
t.type,
|
||||
t.description,
|
||||
le.entry_type,
|
||||
coalesce(
|
||||
(
|
||||
select a2.account_number
|
||||
from ledger_entries le2
|
||||
join accounts a2 on a2.id = le2.account_id
|
||||
where le2.transaction_id = t.id and le2.entry_type = 'debit'
|
||||
limit 1
|
||||
),
|
||||
''
|
||||
) as sender_account,
|
||||
coalesce(
|
||||
(
|
||||
select a2.account_number
|
||||
from ledger_entries le2
|
||||
join accounts a2 on a2.id = le2.account_id
|
||||
where le2.transaction_id = t.id and le2.entry_type = 'credit'
|
||||
limit 1
|
||||
),
|
||||
''
|
||||
) as recipient_account,
|
||||
le.amount_minor,
|
||||
t.created_at
|
||||
from ledger_entries le
|
||||
join transactions t on t.id = le.transaction_id
|
||||
where le.account_id = $1
|
||||
order by t.created_at desc
|
||||
limit $2
|
||||
`, accountID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent transactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []TransactionView
|
||||
for rows.Next() {
|
||||
var item TransactionView
|
||||
if err := rows.Scan(&item.ID, &item.Type, &item.Description, &item.Direction, &item.SenderAccount, &item.RecipientAccount, &item.AmountMinor, &item.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan transaction: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateTransfer(ctx context.Context, senderUserID, destinationAccountNumber, description string, amountMinor int64) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transfer: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
type accountRecord struct {
|
||||
AccountID string
|
||||
UserID string
|
||||
Status string
|
||||
UserStatus string
|
||||
AccountNumber string
|
||||
}
|
||||
|
||||
var sender accountRecord
|
||||
err = tx.QueryRow(ctx, `
|
||||
select a.id, u.id, a.status, u.status, a.account_number
|
||||
from accounts a
|
||||
join users u on u.id = a.user_id
|
||||
where u.id = $1
|
||||
`, senderUserID).Scan(&sender.AccountID, &sender.UserID, &sender.Status, &sender.UserStatus, &sender.AccountNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load sender account: %w", err)
|
||||
}
|
||||
|
||||
var receiver accountRecord
|
||||
err = tx.QueryRow(ctx, `
|
||||
select a.id, u.id, a.status, u.status, a.account_number
|
||||
from accounts a
|
||||
join users u on u.id = a.user_id
|
||||
where a.account_number = $1
|
||||
`, strings.TrimSpace(destinationAccountNumber)).Scan(&receiver.AccountID, &receiver.UserID, &receiver.Status, &receiver.UserStatus, &receiver.AccountNumber)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load receiver account: %w", err)
|
||||
}
|
||||
|
||||
if sender.AccountID == receiver.AccountID {
|
||||
return fmt.Errorf("cannot transfer to the same account")
|
||||
}
|
||||
if sender.UserStatus != "active" || sender.Status != "open" || receiver.UserStatus != "active" || receiver.Status != "open" {
|
||||
return ErrFrozen
|
||||
}
|
||||
|
||||
ids := []string{sender.AccountID, receiver.AccountID}
|
||||
sort.Strings(ids)
|
||||
if _, err := tx.Exec(ctx, `select id from accounts where id = any($1) order by id for update`, ids); err != nil {
|
||||
return fmt.Errorf("lock accounts: %w", err)
|
||||
}
|
||||
|
||||
balance, err := balanceForAccountTx(ctx, tx, sender.AccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if balance < amountMinor {
|
||||
return ErrInsufficientFunds
|
||||
}
|
||||
|
||||
transactionID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
debitID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creditID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
|
||||
values ($1, 'transfer', 'completed', $2, $3, $4, now())
|
||||
`, transactionID, reference, senderUserID, strings.TrimSpace(description))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert transfer 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()),
|
||||
($5, $2, $6, 'credit', $4, now())
|
||||
`, debitID, transactionID, sender.AccountID, amountMinor, creditID, receiver.AccountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert ledger entries: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit transfer: %w", err)
|
||||
}
|
||||
|
||||
return 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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `select count(*) from accounts`).Scan(&dashboard.AccountCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `select count(*) from transactions`).Scan(&dashboard.TransactionCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `select count(*) from users where status = 'frozen'`).Scan(&dashboard.FrozenUserCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
select t.id, t.type, t.description, le.entry_type,
|
||||
coalesce((select a.account_number from ledger_entries le2 join accounts a on a.id = le2.account_id where le2.transaction_id = t.id and le2.entry_type = 'debit' limit 1), ''),
|
||||
coalesce((select a.account_number from ledger_entries le2 join accounts a on a.id = le2.account_id where le2.transaction_id = t.id and le2.entry_type = 'credit' limit 1), ''),
|
||||
le.amount_minor,
|
||||
t.created_at
|
||||
from transactions t
|
||||
join ledger_entries le on le.transaction_id = t.id
|
||||
order by t.created_at desc
|
||||
limit 10
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item TransactionView
|
||||
if err := rows.Scan(&item.ID, &item.Type, &item.Description, &item.Direction, &item.SenderAccount, &item.RecipientAccount, &item.AmountMinor, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dashboard.RecentTransactions = append(dashboard.RecentTransactions, item)
|
||||
}
|
||||
|
||||
auditRows, err := s.pool.Query(ctx, `
|
||||
select al.action, al.target_type, al.target_id, al.metadata::text, al.created_at, au.email
|
||||
from audit_logs al
|
||||
join admin_users au on au.id = al.actor_admin_id
|
||||
order by al.created_at desc
|
||||
limit 10
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer auditRows.Close()
|
||||
for auditRows.Next() {
|
||||
var item AuditLog
|
||||
if err := auditRows.Scan(&item.Action, &item.TargetType, &item.TargetID, &item.Metadata, &item.CreatedAt, &item.AdminEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dashboard.RecentAuditActivity = append(dashboard.RecentAuditActivity, item)
|
||||
}
|
||||
|
||||
return &dashboard, nil
|
||||
}
|
||||
|
||||
func (s *Store) SearchUsers(ctx context.Context, search string) ([]UserListItem, error) {
|
||||
search = strings.TrimSpace(search)
|
||||
pattern := "%" + strings.ToLower(search) + "%"
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
select u.id, u.email, u.username, u.status, a.account_number, u.created_at,
|
||||
coalesce(sum(case when le.entry_type = 'credit' then le.amount_minor else -le.amount_minor end), 0) as balance_minor
|
||||
from users u
|
||||
join accounts a on a.user_id = u.id
|
||||
left join ledger_entries le on le.account_id = a.id
|
||||
where ($1 = '' or lower(u.email) like $2 or lower(u.username) like $2 or a.account_number like $2)
|
||||
group by u.id, u.email, u.username, u.status, a.account_number, u.created_at
|
||||
order by u.created_at desc
|
||||
limit 100
|
||||
`, search, pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []UserListItem
|
||||
for rows.Next() {
|
||||
var item UserListItem
|
||||
if err := rows.Scan(&item.ID, &item.Email, &item.Username, &item.Status, &item.AccountNumber, &item.CreatedAt, &item.BalanceMinor); err != nil {
|
||||
return nil, fmt.Errorf("scan user list item: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Leaderboard(ctx context.Context, limit int) ([]LeaderboardEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
select
|
||||
u.username,
|
||||
a.account_number,
|
||||
coalesce(sum(case when le.entry_type = 'credit' then le.amount_minor else -le.amount_minor end), 0) as balance_minor
|
||||
from users u
|
||||
join accounts a on a.user_id = u.id
|
||||
left join ledger_entries le on le.account_id = a.id
|
||||
where u.status = 'active' and a.status = 'open'
|
||||
group by u.id, u.username, a.account_number
|
||||
order by balance_minor desc, u.username asc
|
||||
limit $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("leaderboard: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []LeaderboardEntry
|
||||
rank := 1
|
||||
for rows.Next() {
|
||||
var entry LeaderboardEntry
|
||||
if err := rows.Scan(&entry.Username, &entry.AccountNumber, &entry.BalanceMinor); err != nil {
|
||||
return nil, fmt.Errorf("scan leaderboard entry: %w", err)
|
||||
}
|
||||
entry.Rank = rank
|
||||
rank++
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetUserDetail(ctx context.Context, userID string) (*User, []TransactionView, error) {
|
||||
user, err := s.GetUserDashboard(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
transactions, err := s.RecentTransactionsForAccount(ctx, user.AccountID, 50)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return user, transactions, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetUserStatus(ctx context.Context, userID, status, adminID string) error {
|
||||
result, err := s.pool.Exec(ctx, `update users set status = $2, updated_at = now() where id = $1`, userID, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user status: %w", err)
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
metadata, err := json.Marshal(map[string]string{"status": status})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal user status audit log: %w", err)
|
||||
}
|
||||
|
||||
return s.insertAuditLog(ctx, adminID, "user_status_changed", "user", userID, string(metadata))
|
||||
}
|
||||
|
||||
func (s *Store) CreateAdjustment(ctx context.Context, adminID, accountID, direction string, amountMinor int64, reason string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin adjustment: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var userStatus string
|
||||
err = tx.QueryRow(ctx, `
|
||||
select u.status
|
||||
from accounts a
|
||||
join users u on u.id = a.user_id
|
||||
where a.id = $1
|
||||
for update
|
||||
`, accountID).Scan(&userStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load account for adjustment: %w", err)
|
||||
}
|
||||
if userStatus != "active" {
|
||||
return ErrFrozen
|
||||
}
|
||||
|
||||
if direction == "debit" {
|
||||
balance, err := balanceForAccountTx(ctx, tx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if balance < amountMinor {
|
||||
return ErrInsufficientFunds
|
||||
}
|
||||
}
|
||||
|
||||
transactionID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into transactions (id, type, status, reference, initiated_by_admin_id, description, created_at)
|
||||
values ($1, 'adjustment', 'completed', $2, $3, $4, now())
|
||||
`, transactionID, reference, adminID, strings.TrimSpace(reason))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert adjustment 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, $4, $5, now())
|
||||
`, entryID, transactionID, accountID, direction, amountMinor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert adjustment entry: %w", err)
|
||||
}
|
||||
|
||||
metadataBytes, err := json.Marshal(map[string]any{
|
||||
"direction": direction,
|
||||
"amount_minor": amountMinor,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal adjustment metadata: %w", err)
|
||||
}
|
||||
logID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into audit_logs (id, actor_admin_id, action, target_type, target_id, metadata, created_at)
|
||||
values ($1, $2, 'balance_adjustment', 'account', $3, $4::jsonb, now())
|
||||
`, logID, adminID, accountID, string(metadataBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert adjustment audit log: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit adjustment: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AuditLogs(ctx context.Context) ([]AuditLog, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
select al.action, al.target_type, al.target_id, al.metadata::text, al.created_at, au.email
|
||||
from audit_logs al
|
||||
join admin_users au on au.id = al.actor_admin_id
|
||||
order by al.created_at desc
|
||||
limit 100
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
var item AuditLog
|
||||
if err := rows.Scan(&item.Action, &item.TargetType, &item.TargetID, &item.Metadata, &item.CreatedAt, &item.AdminEmail); err != nil {
|
||||
return nil, fmt.Errorf("scan audit log: %w", err)
|
||||
}
|
||||
logs = append(logs, item)
|
||||
}
|
||||
|
||||
return logs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) insertAuditLog(ctx context.Context, adminID, action, targetType, targetID, metadata string) error {
|
||||
id, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
insert into audit_logs (id, actor_admin_id, action, target_type, target_id, metadata, created_at)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb, now())
|
||||
`, id, adminID, action, targetType, targetID, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) generateUniqueAccountNumber(ctx context.Context, tx pgx.Tx) (string, error) {
|
||||
for i := 0; i < 20; i++ {
|
||||
candidate, err := randomAccountNumber()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `select exists(select 1 from accounts where account_number = $1)`, candidate).Scan(&exists); err != nil {
|
||||
return "", fmt.Errorf("check account number uniqueness: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not generate unique account number")
|
||||
}
|
||||
|
||||
func randomAccountNumber() (string, error) {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", fmt.Errorf("read random account bytes: %w", err)
|
||||
}
|
||||
n := binary.BigEndian.Uint64(buf[:]) % 10000000000
|
||||
if n < 1000000000 {
|
||||
n += 1000000000
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%010d", n), nil
|
||||
}
|
||||
|
||||
func balanceForAccountTx(ctx context.Context, tx pgx.Tx, accountID string) (int64, error) {
|
||||
var balance int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
select coalesce(sum(case when entry_type = 'credit' then amount_minor else -amount_minor end), 0)
|
||||
from ledger_entries
|
||||
where account_id = $1
|
||||
`, accountID).Scan(&balance)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("balance for account tx: %w", err)
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
703
internal/http/app.go
Normal file
703
internal/http/app.go
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"superbanka/internal/auth"
|
||||
"superbanka/internal/config"
|
||||
"superbanka/internal/db"
|
||||
"superbanka/internal/platform"
|
||||
)
|
||||
|
||||
const (
|
||||
userSessionCookie = "superbanka_user_session"
|
||||
adminSessionCookie = "superbanka_admin_session"
|
||||
csrfCookie = "superbanka_csrf"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ctxUserKey contextKey = "current-user"
|
||||
ctxAdminKey contextKey = "current-admin"
|
||||
ctxCSRFKey contextKey = "csrf-token"
|
||||
flashCookie = "superbanka_flash"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
dbPool *pgxpool.Pool
|
||||
store *db.Store
|
||||
renderer *Renderer
|
||||
router http.Handler
|
||||
}
|
||||
|
||||
type HomeData struct {
|
||||
UserCount int
|
||||
TransactionCount int
|
||||
}
|
||||
|
||||
type DashboardData struct {
|
||||
User *db.User
|
||||
Transactions []db.TransactionView
|
||||
}
|
||||
|
||||
type AdminDashboardData struct {
|
||||
Admin *db.AdminUser
|
||||
Dashboard *db.AdminDashboard
|
||||
}
|
||||
|
||||
type UsersPageData struct {
|
||||
Admin *db.AdminUser
|
||||
Search string
|
||||
Users []db.UserListItem
|
||||
}
|
||||
|
||||
type UserDetailData struct {
|
||||
Admin *db.AdminUser
|
||||
User *db.User
|
||||
Transactions []db.TransactionView
|
||||
}
|
||||
|
||||
type LeaderboardData struct {
|
||||
Entries []db.LeaderboardEntry
|
||||
}
|
||||
|
||||
func NewApp(cfg config.Config) (*App, error) {
|
||||
ctx := context.Background()
|
||||
dbPool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db pool: %w", err)
|
||||
}
|
||||
|
||||
if err := dbPool.Ping(ctx); err != nil {
|
||||
dbPool.Close()
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
|
||||
if err := db.RunMigrations(ctx, dbPool, filepath.Join("migrations")); err != nil {
|
||||
dbPool.Close()
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
store := db.NewStore(dbPool)
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
dbPool.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
adminHash, err := auth.HashPassword(cfg.AdminPass)
|
||||
if err != nil {
|
||||
dbPool.Close()
|
||||
return nil, fmt.Errorf("hash bootstrap admin password: %w", err)
|
||||
}
|
||||
if err := store.BootstrapAdmin(ctx, cfg.AdminEmail, adminHash); err != nil {
|
||||
dbPool.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
dbPool: dbPool,
|
||||
store: store,
|
||||
renderer: renderer,
|
||||
}
|
||||
app.router = app.routes()
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) Router() http.Handler {
|
||||
return a.router
|
||||
}
|
||||
|
||||
func (a *App) Close() {
|
||||
a.dbPool.Close()
|
||||
}
|
||||
|
||||
func (a *App) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RedirectSlashes)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
r.Use(a.withCSRF)
|
||||
r.Use(a.withCurrentSessions)
|
||||
|
||||
r.Get("/healthz", a.healthz)
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join("web", "static")))))
|
||||
|
||||
r.Get("/", a.home)
|
||||
r.Get("/leaderboard", a.leaderboard)
|
||||
r.Get("/register", a.showRegister)
|
||||
r.Post("/register", a.register)
|
||||
r.Get("/login", a.showLogin)
|
||||
r.Post("/login", a.login)
|
||||
r.Post("/logout", a.logout)
|
||||
|
||||
r.Get("/admin/login", a.showAdminLogin)
|
||||
r.Post("/admin/login", a.adminLogin)
|
||||
r.Post("/admin/logout", a.adminLogout)
|
||||
|
||||
r.Group(func(protected chi.Router) {
|
||||
protected.Use(a.requireUser)
|
||||
protected.Get("/app", a.userDashboard)
|
||||
protected.Get("/app/transfer", a.showTransfer)
|
||||
protected.Post("/app/transfer", a.createTransfer)
|
||||
protected.Get("/app/transactions", a.transactionsPage)
|
||||
})
|
||||
|
||||
r.Route("/admin", func(admin chi.Router) {
|
||||
admin.Use(a.requireAdmin)
|
||||
admin.Get("/", a.adminDashboard)
|
||||
admin.Get("/users", a.adminUsers)
|
||||
admin.Get("/users/{userID}", a.adminUserDetail)
|
||||
admin.Post("/users/{userID}/freeze", a.freezeUser)
|
||||
admin.Post("/users/{userID}/unfreeze", a.unfreezeUser)
|
||||
admin.Post("/accounts/{accountID}/adjust", a.adjustBalance)
|
||||
admin.Get("/audit-logs", a.auditLogs)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *App) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (a *App) home(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
admin, _ := a.currentAdmin(r)
|
||||
|
||||
dashboard, err := a.store.AdminDashboard(r.Context())
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.render(w, http.StatusOK, ViewData{
|
||||
Title: "Superbanka",
|
||||
Page: "home",
|
||||
User: user,
|
||||
Admin: admin,
|
||||
CSRFToken: a.csrfToken(r),
|
||||
Flash: a.readFlash(w, r),
|
||||
Data: HomeData{
|
||||
UserCount: dashboard.UserCount,
|
||||
TransactionCount: dashboard.TransactionCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) leaderboard(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
admin, _ := a.currentAdmin(r)
|
||||
|
||||
entries, err := a.store.Leaderboard(r.Context(), 100)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.render(w, http.StatusOK, ViewData{
|
||||
Title: "Leaderboard",
|
||||
Page: "leaderboard",
|
||||
User: user,
|
||||
Admin: admin,
|
||||
CSRFToken: a.csrfToken(r),
|
||||
Flash: a.readFlash(w, r),
|
||||
Data: LeaderboardData{Entries: entries},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) showRegister(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, http.StatusOK, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
|
||||
}
|
||||
|
||||
func (a *App) register(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.verifyCSRF(r) {
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Error: "Invalid form token."})
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if email == "" || username == "" || len(password) < 8 {
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Error: "Email, username, and a password with at least 8 characters are required."})
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.store.CreateUser(r.Context(), email, username, passwordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
a.render(w, http.StatusConflict, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Error: "Email or username is already taken."})
|
||||
return
|
||||
}
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.startUserSession(w, r, user.ID); err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.setFlash(w, "Account created. Your signup bonus of 10 000,00 CZK is ready.")
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) showLogin(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, http.StatusOK, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
|
||||
}
|
||||
|
||||
func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.verifyCSRF(r) {
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Error: "Invalid form token."})
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, hash, err := a.store.GetUserByEmail(r.Context(), r.FormValue("email"))
|
||||
if err != nil || !auth.CheckPassword(hash, r.FormValue("password")) {
|
||||
a.render(w, http.StatusUnauthorized, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Error: "Invalid email or password."})
|
||||
return
|
||||
}
|
||||
if user.Status != "active" {
|
||||
a.render(w, http.StatusForbidden, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Error: "This user is frozen."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.startUserSession(w, r, user.ID); err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.verifyCSRF(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if cookie, err := r.Cookie(userSessionCookie); err == nil {
|
||||
_ = a.store.DeleteUserSession(r.Context(), auth.HashToken(cookie.Value))
|
||||
}
|
||||
a.clearCookie(w, userSessionCookie)
|
||||
a.setFlash(w, "Signed out.")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) userDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
transactions, err := a.store.RecentTransactionsForAccount(r.Context(), user.AccountID, 10)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.render(w, http.StatusOK, ViewData{
|
||||
Title: "Dashboard",
|
||||
Page: "dashboard",
|
||||
User: user,
|
||||
CSRFToken: a.csrfToken(r),
|
||||
Flash: a.readFlash(w, r),
|
||||
Data: DashboardData{User: user, Transactions: transactions},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) showTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
a.render(w, http.StatusOK, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
|
||||
}
|
||||
|
||||
func (a *App) createTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
if !a.verifyCSRF(r) {
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "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.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "Enter a valid positive CZK amount."})
|
||||
return
|
||||
}
|
||||
|
||||
err = a.store.CreateTransfer(r.Context(), user.ID, r.FormValue("account_number"), r.FormValue("description"), amountMinor)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrInsufficientFunds):
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "Insufficient funds."})
|
||||
case errors.Is(err, db.ErrFrozen):
|
||||
a.render(w, http.StatusForbidden, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "This transfer is not allowed because one of the accounts is frozen."})
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "Destination account number was not found."})
|
||||
default:
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.setFlash(w, "Transfer completed.")
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) transactionsPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
transactions, err := a.store.RecentTransactionsForAccount(r.Context(), user.AccountID, 100)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
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) 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)})
|
||||
}
|
||||
|
||||
func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.verifyCSRF(r) {
|
||||
a.render(w, http.StatusBadRequest, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Error: "Invalid form token."})
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
admin, hash, err := a.store.GetAdminByEmail(r.Context(), r.FormValue("email"))
|
||||
if err != nil || !auth.CheckPassword(hash, r.FormValue("password")) {
|
||||
a.render(w, http.StatusUnauthorized, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Error: "Invalid email or password."})
|
||||
return
|
||||
}
|
||||
if admin.Status != "active" {
|
||||
a.render(w, http.StatusForbidden, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Error: "This admin account is inactive."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.startAdminSession(w, r, admin.ID); err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.verifyCSRF(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if cookie, err := r.Cookie(adminSessionCookie); err == nil {
|
||||
_ = a.store.DeleteAdminSession(r.Context(), auth.HashToken(cookie.Value))
|
||||
}
|
||||
a.clearCookie(w, adminSessionCookie)
|
||||
a.setFlash(w, "Admin signed out.")
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := a.currentAdmin(r)
|
||||
dashboard, err := a.store.AdminDashboard(r.Context())
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
a.render(w, http.StatusOK, ViewData{Title: "Admin dashboard", Page: "admin_dashboard", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: AdminDashboardData{Admin: admin, Dashboard: dashboard}})
|
||||
}
|
||||
|
||||
func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := a.currentAdmin(r)
|
||||
search := r.URL.Query().Get("q")
|
||||
users, err := a.store.SearchUsers(r.Context(), search)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
a.render(w, http.StatusOK, ViewData{Title: "Users", Page: "admin_users", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: UsersPageData{Admin: admin, Search: search, Users: users}})
|
||||
}
|
||||
|
||||
func (a *App) adminUserDetail(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := a.currentAdmin(r)
|
||||
user, transactions, err := a.store.GetUserDetail(r.Context(), chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
a.render(w, http.StatusOK, ViewData{Title: "User detail", Page: "admin_user_detail", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: UserDetailData{Admin: admin, User: user, Transactions: transactions}})
|
||||
}
|
||||
|
||||
func (a *App) freezeUser(w http.ResponseWriter, r *http.Request) {
|
||||
a.changeUserStatus(w, r, "frozen")
|
||||
}
|
||||
|
||||
func (a *App) unfreezeUser(w http.ResponseWriter, r *http.Request) {
|
||||
a.changeUserStatus(w, r, "active")
|
||||
}
|
||||
|
||||
func (a *App) changeUserStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
admin, _ := a.currentAdmin(r)
|
||||
if !a.verifyCSRF(r) {
|
||||
a.setFlash(w, "Invalid form token.")
|
||||
http.Redirect(w, r, "/admin/users/"+chi.URLParam(r, "userID"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.store.SetUserStatus(r.Context(), chi.URLParam(r, "userID"), status, admin.ID); err != nil {
|
||||
a.setFlash(w, err.Error())
|
||||
} else {
|
||||
a.setFlash(w, "User status updated.")
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users/"+chi.URLParam(r, "userID"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) adjustBalance(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := a.currentAdmin(r)
|
||||
if !a.verifyCSRF(r) {
|
||||
a.setFlash(w, "Invalid form token.")
|
||||
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
|
||||
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.setFlash(w, "Invalid adjustment amount.")
|
||||
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.store.CreateAdjustment(r.Context(), admin.ID, chi.URLParam(r, "accountID"), r.FormValue("direction"), amountMinor, r.FormValue("reason")); err != nil {
|
||||
a.setFlash(w, err.Error())
|
||||
} else {
|
||||
a.setFlash(w, "Balance adjusted.")
|
||||
}
|
||||
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) auditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := a.currentAdmin(r)
|
||||
logs, err := a.store.AuditLogs(r.Context())
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
a.render(w, http.StatusOK, ViewData{Title: "Audit logs", Page: "admin_audit_logs", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: logs})
|
||||
}
|
||||
|
||||
func (a *App) withCurrentSessions(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if cookie, err := r.Cookie(userSessionCookie); err == nil && cookie.Value != "" {
|
||||
if user, err := a.store.UserFromSession(ctx, auth.HashToken(cookie.Value)); err == nil {
|
||||
ctx = context.WithValue(ctx, ctxUserKey, user)
|
||||
}
|
||||
}
|
||||
if cookie, err := r.Cookie(adminSessionCookie); err == nil && cookie.Value != "" {
|
||||
if admin, err := a.store.AdminFromSession(ctx, auth.HashToken(cookie.Value)); err == nil {
|
||||
ctx = context.WithValue(ctx, ctxAdminKey, admin)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireUser(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
if user == nil {
|
||||
a.setFlash(w, "Please sign in first.")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.Status != "active" {
|
||||
a.clearCookie(w, userSessionCookie)
|
||||
a.setFlash(w, "Your user is frozen.")
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := a.currentAdmin(r)
|
||||
if admin == nil {
|
||||
a.setFlash(w, "Please sign in as admin.")
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) startUserSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token, err := platform.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := time.Now().Add(24 * time.Hour)
|
||||
if err := a.store.CreateUserSession(r.Context(), userID, auth.HashToken(token), expires); err != nil {
|
||||
return err
|
||||
}
|
||||
a.setCookie(w, userSessionCookie, token, expires)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) startAdminSession(w http.ResponseWriter, r *http.Request, adminID string) error {
|
||||
token, err := platform.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := time.Now().Add(24 * time.Hour)
|
||||
if err := a.store.CreateAdminSession(r.Context(), adminID, auth.HashToken(token), expires); err != nil {
|
||||
return err
|
||||
}
|
||||
a.setCookie(w, adminSessionCookie, token, expires)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) withCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := ""
|
||||
if cookie, err := r.Cookie(csrfCookie); err == nil && cookie.Value != "" {
|
||||
token = cookie.Value
|
||||
} else {
|
||||
var err error
|
||||
token, err = platform.NewToken()
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
a.setCookie(w, csrfCookie, token, time.Now().Add(7*24*time.Hour))
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxCSRFKey, token)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) csrfToken(r *http.Request) string {
|
||||
if token, ok := r.Context().Value(ctxCSRFKey).(string); ok && token != "" {
|
||||
return token
|
||||
}
|
||||
cookie, err := r.Cookie(csrfCookie)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func (a *App) verifyCSRF(r *http.Request) bool {
|
||||
return a.csrfToken(r) != "" && a.csrfToken(r) == r.FormValue("csrf_token")
|
||||
}
|
||||
|
||||
func (a *App) currentUser(r *http.Request) (*db.User, bool) {
|
||||
user, ok := r.Context().Value(ctxUserKey).(*db.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func (a *App) currentAdmin(r *http.Request) (*db.AdminUser, bool) {
|
||||
admin, ok := r.Context().Value(ctxAdminKey).(*db.AdminUser)
|
||||
return admin, ok
|
||||
}
|
||||
|
||||
func (a *App) setCookie(w http.ResponseWriter, name, value string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: a.cfg.AppEnv == "production",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) clearCookie(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: a.cfg.AppEnv == "production",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) setFlash(w http.ResponseWriter, message string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: flashCookie,
|
||||
Value: url.QueryEscape(message),
|
||||
Path: "/",
|
||||
MaxAge: 60,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: a.cfg.AppEnv == "production",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) readFlash(w http.ResponseWriter, r *http.Request) string {
|
||||
cookie, err := r.Cookie(flashCookie)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return ""
|
||||
}
|
||||
a.clearCookie(w, flashCookie)
|
||||
message, err := url.QueryUnescape(cookie.Value)
|
||||
if err != nil {
|
||||
return cookie.Value
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func (a *App) render(w http.ResponseWriter, status int, data ViewData) {
|
||||
a.renderer.HTML(w, status, data)
|
||||
}
|
||||
|
||||
func (a *App) serverError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, fmt.Sprintf("server error: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
60
internal/http/render.go
Normal file
60
internal/http/render.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"superbanka/internal/platform"
|
||||
)
|
||||
|
||||
type ViewData struct {
|
||||
Title string
|
||||
Page string
|
||||
CSRFToken string
|
||||
Flash string
|
||||
Error string
|
||||
User any
|
||||
Admin any
|
||||
Data any
|
||||
}
|
||||
|
||||
type Renderer struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
funcs := template.FuncMap{
|
||||
"money": platform.FormatCZK,
|
||||
"dt": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.Format("02. 01. 2006 15:04")
|
||||
},
|
||||
}
|
||||
|
||||
templates, err := template.New("all").Funcs(funcs).ParseGlob(filepath.Join("web", "templates", "layouts", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse layout templates: %w", err)
|
||||
}
|
||||
|
||||
templates, err = templates.ParseGlob(filepath.Join("web", "templates", "pages", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse page templates: %w", err)
|
||||
}
|
||||
|
||||
return &Renderer{templates: templates}, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) HTML(w http.ResponseWriter, status int, data ViewData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := r.templates.ExecuteTemplate(w, "layouts/base", data); err != nil {
|
||||
http.Error(w, fmt.Sprintf("render template: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
25
internal/platform/id.go
Normal file
25
internal/platform/id.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package platform
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func NewID() (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random id bytes: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func NewToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random token bytes: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
79
internal/platform/money.go
Normal file
79
internal/platform/money.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatCZK(amountMinor int64) string {
|
||||
sign := ""
|
||||
if amountMinor < 0 {
|
||||
sign = "-"
|
||||
amountMinor = -amountMinor
|
||||
}
|
||||
|
||||
whole := amountMinor / 100
|
||||
fraction := amountMinor % 100
|
||||
|
||||
return fmt.Sprintf("%s%s,%02d CZK", sign, formatThousands(whole), fraction)
|
||||
}
|
||||
|
||||
func ParseCZK(input string) (int64, error) {
|
||||
clean := strings.TrimSpace(strings.ReplaceAll(input, " ", ""))
|
||||
clean = strings.ReplaceAll(clean, ",", ".")
|
||||
if clean == "" {
|
||||
return 0, fmt.Errorf("amount is required")
|
||||
}
|
||||
|
||||
parts := strings.Split(clean, ".")
|
||||
if len(parts) > 2 {
|
||||
return 0, fmt.Errorf("invalid amount")
|
||||
}
|
||||
|
||||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid whole amount")
|
||||
}
|
||||
|
||||
var fraction int64
|
||||
if len(parts) == 2 {
|
||||
frac := parts[1]
|
||||
if len(frac) > 2 {
|
||||
return 0, fmt.Errorf("too many decimal places")
|
||||
}
|
||||
if len(frac) == 1 {
|
||||
frac += "0"
|
||||
}
|
||||
if len(frac) == 0 {
|
||||
frac = "00"
|
||||
}
|
||||
|
||||
fraction, err = strconv.ParseInt(frac, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid decimal amount")
|
||||
}
|
||||
}
|
||||
|
||||
if whole < 0 || fraction < 0 {
|
||||
return 0, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
return whole*100 + fraction, nil
|
||||
}
|
||||
|
||||
func formatThousands(value int64) string {
|
||||
plain := strconv.FormatInt(value, 10)
|
||||
if len(plain) <= 3 {
|
||||
return plain
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for len(plain) > 3 {
|
||||
parts = append([]string{plain[len(plain)-3:]}, parts...)
|
||||
plain = plain[:len(plain)-3]
|
||||
}
|
||||
parts = append([]string{plain}, parts...)
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
31
internal/platform/money_test.go
Normal file
31
internal/platform/money_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCZK(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
}{
|
||||
{input: "100", want: 10000},
|
||||
{input: "100.50", want: 10050},
|
||||
{input: "100,5", want: 10050},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := ParseCZK(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCZK(%q) returned error: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ParseCZK(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCZK(t *testing.T) {
|
||||
got := FormatCZK(1234567)
|
||||
if got != "12 345,67 CZK" {
|
||||
t.Fatalf("FormatCZK() = %q", got)
|
||||
}
|
||||
}
|
||||
81
migrations/001_init.sql
Normal file
81
migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
create table if not exists users (
|
||||
id text primary key,
|
||||
email text not null unique,
|
||||
username text not null unique,
|
||||
password_hash text not null,
|
||||
status text not null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists admin_users (
|
||||
id text primary key,
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
role text not null,
|
||||
status text not null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists accounts (
|
||||
id text primary key,
|
||||
user_id text not null unique references users(id) on delete cascade,
|
||||
account_number text not null unique,
|
||||
currency char(3) not null,
|
||||
status text not null,
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists transactions (
|
||||
id text primary key,
|
||||
type text not null,
|
||||
status text not null,
|
||||
reference text not null unique,
|
||||
initiated_by_user_id text references users(id),
|
||||
initiated_by_admin_id text references admin_users(id),
|
||||
description text not null default '',
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists ledger_entries (
|
||||
id text primary key,
|
||||
transaction_id text not null references transactions(id) on delete cascade,
|
||||
account_id text not null references accounts(id) on delete cascade,
|
||||
entry_type text not null,
|
||||
amount_minor bigint not null check (amount_minor > 0),
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists user_sessions (
|
||||
id text primary key,
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists admin_sessions (
|
||||
id text primary key,
|
||||
admin_user_id text not null references admin_users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists audit_logs (
|
||||
id text primary key,
|
||||
actor_admin_id text not null references admin_users(id),
|
||||
action text not null,
|
||||
target_type text not null,
|
||||
target_id text not null,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create index if not exists idx_accounts_account_number on accounts(account_number);
|
||||
create index if not exists idx_ledger_entries_account_id_created_at on ledger_entries(account_id, created_at desc);
|
||||
create index if not exists idx_transactions_created_at on transactions(created_at desc);
|
||||
create index if not exists idx_user_sessions_token_hash on user_sessions(token_hash);
|
||||
create index if not exists idx_admin_sessions_token_hash on admin_sessions(token_hash);
|
||||
create index if not exists idx_audit_logs_created_at on audit_logs(created_at desc);
|
||||
430
web/static/app.css
Normal file
430
web/static/app.css
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f7fb;
|
||||
--panel: #ffffff;
|
||||
--text: #18202a;
|
||||
--muted: #667487;
|
||||
--line: #d9e1ec;
|
||||
--primary: #1540d1;
|
||||
--primary-strong: #102ea0;
|
||||
--accent: #dfe8ff;
|
||||
--success: #0b7a45;
|
||||
--danger: #b42318;
|
||||
--shadow: 0 18px 45px rgba(21, 32, 56, 0.08);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1120px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid rgba(217, 225, 236, 0.7);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: -0.03em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 48px 0 72px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.9fr);
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.78rem;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.4rem, 5vw, 4.5rem);
|
||||
line-height: 0.98;
|
||||
margin: 10px 0 20px;
|
||||
letter-spacing: -0.05em;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 13px 18px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: var(--primary-strong);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: var(--panel);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(217, 225, 236, 0.95);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(217, 225, 236, 0.95);
|
||||
border-radius: 24px;
|
||||
padding: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.narrow {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stack-form input,
|
||||
.stack-form select,
|
||||
.search-form input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 13px 14px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.flash.success {
|
||||
background: #eaf8f0;
|
||||
color: var(--success);
|
||||
border-color: #b7e3c8;
|
||||
}
|
||||
|
||||
.flash.error {
|
||||
background: #fff2f1;
|
||||
color: var(--danger);
|
||||
border-color: #f3c7c2;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.subtle,
|
||||
.meta-line {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
background: linear-gradient(150deg, #0e1b4f 0%, #1540d1 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.compact-balance-card {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.balance-card .eyebrow,
|
||||
.balance-card .meta-line {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
max-width: none;
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.search-form,
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
min-width: min(100%, 360px);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.stat-box span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-box strong,
|
||||
.stats-grid dd {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.stats-grid dt {
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stats-grid dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.transaction-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.transaction-table th,
|
||||
.transaction-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.transaction-table th:last-child,
|
||||
.transaction-table td:last-child {
|
||||
white-space: normal;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.transaction-table th:first-child,
|
||||
.transaction-table td:first-child {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.transaction-table th:nth-child(4),
|
||||
.transaction-table td:nth-child(4),
|
||||
.transaction-table th:nth-child(5),
|
||||
.transaction-table td:nth-child(5) {
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.transaction-table th:nth-child(6),
|
||||
.transaction-table td:nth-child(6) {
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.leaderboard-note {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rank-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--primary-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.leaderboard-table td:last-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stack-section {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stats-card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 14px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.clean-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.clean-list li {
|
||||
color: var(--muted);
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.clean-list li:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
78
web/templates/layouts/base.gohtml
Normal file
78
web/templates/layouts/base.gohtml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{{ define "layouts/base" }}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="shell header-row">
|
||||
<a class="brand" href="/">Superbanka</a>
|
||||
<nav class="nav">
|
||||
{{ if .Admin }}
|
||||
<a href="/admin">Dashboard</a>
|
||||
<a href="/leaderboard">Leaderboard</a>
|
||||
<a href="/admin/users">Users</a>
|
||||
<a href="/admin/audit-logs">Audit logs</a>
|
||||
<form method="post" action="/admin/logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<button class="nav-button" type="submit">Admin logout</button>
|
||||
</form>
|
||||
{{ else if .User }}
|
||||
<a href="/app">Dashboard</a>
|
||||
<a href="/leaderboard">Leaderboard</a>
|
||||
<a href="/app/transfer">Transfer</a>
|
||||
<a href="/app/transactions">Transactions</a>
|
||||
<form method="post" action="/logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<button class="nav-button" type="submit">Logout</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<a href="/leaderboard">Leaderboard</a>
|
||||
<a href="/register">Register</a>
|
||||
<a href="/login">Login</a>
|
||||
<a href="/admin/login">Admin</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shell main-content">
|
||||
{{ if .Flash }}
|
||||
<div class="flash success">{{ .Flash }}</div>
|
||||
{{ end }}
|
||||
{{ if .Error }}
|
||||
<div class="flash error">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
{{ if eq .Page "home" }}
|
||||
{{ template "home" . }}
|
||||
{{ else if eq .Page "register" }}
|
||||
{{ template "register" . }}
|
||||
{{ else if eq .Page "login" }}
|
||||
{{ template "login" . }}
|
||||
{{ else if eq .Page "admin_login" }}
|
||||
{{ template "admin_login" . }}
|
||||
{{ else if eq .Page "dashboard" }}
|
||||
{{ template "dashboard" . }}
|
||||
{{ else if eq .Page "leaderboard" }}
|
||||
{{ template "leaderboard" . }}
|
||||
{{ else if eq .Page "transfer" }}
|
||||
{{ template "transfer" . }}
|
||||
{{ else if eq .Page "transactions" }}
|
||||
{{ template "transactions" . }}
|
||||
{{ else if eq .Page "admin_dashboard" }}
|
||||
{{ template "admin_dashboard" . }}
|
||||
{{ else if eq .Page "admin_users" }}
|
||||
{{ template "admin_users" . }}
|
||||
{{ else if eq .Page "admin_user_detail" }}
|
||||
{{ template "admin_user_detail" . }}
|
||||
{{ else if eq .Page "admin_audit_logs" }}
|
||||
{{ template "admin_audit_logs" . }}
|
||||
{{ end }}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
300
web/templates/pages/home.gohtml
Normal file
300
web/templates/pages/home.gohtml
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
{{ define "home" }}
|
||||
{{ $stats := .Data }}
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Fast SSR banking demo</p>
|
||||
<h1>Transfer money by account number with real ledger rules.</h1>
|
||||
<p class="lede">Each new user receives a generated CZK account number and a 10 000,00 CZK signup bonus. Admins use a separate login and every privileged action is audited.</p>
|
||||
<div class="actions">
|
||||
<a class="button primary" href="/register">Create account</a>
|
||||
<a class="button secondary" href="/login">User login</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="card stats-card">
|
||||
<h2>System snapshot</h2>
|
||||
<dl class="stats-grid">
|
||||
<div>
|
||||
<dt>Registered users</dt>
|
||||
<dd>{{ $stats.UserCount }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Transactions</dt>
|
||||
<dd>{{ $stats.TransactionCount }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ul class="clean-list">
|
||||
<li>Internal transfers by account number</li>
|
||||
<li>Separate admin authentication</li>
|
||||
<li>Signup bonus on registration</li>
|
||||
<li>Admin freeze, unfreeze, and adjustments</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "register" }}
|
||||
<section class="panel narrow">
|
||||
<h1>Create user account</h1>
|
||||
<p class="muted">Every signup gets a new account number and 10 000,00 CZK.</p>
|
||||
<form method="post" action="/register" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<label>Email<input type="email" name="email" required></label>
|
||||
<label>Username<input type="text" name="username" required></label>
|
||||
<label>Password<input type="password" name="password" minlength="8" required></label>
|
||||
<button class="button primary" type="submit">Create account</button>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "login" }}
|
||||
<section class="panel narrow">
|
||||
<h1>User login</h1>
|
||||
<form method="post" action="/login" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<label>Email<input type="email" name="email" required></label>
|
||||
<label>Password<input type="password" name="password" required></label>
|
||||
<button class="button primary" type="submit">Sign in</button>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_login" }}
|
||||
<section class="panel narrow">
|
||||
<h1>Admin login</h1>
|
||||
<p class="muted">Separate authentication from customer accounts.</p>
|
||||
<form method="post" action="/admin/login" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<label>Email<input type="email" name="email" required></label>
|
||||
<label>Password<input type="password" name="password" required></label>
|
||||
<button class="button primary" type="submit">Admin sign in</button>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "dashboard" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="stack-section">
|
||||
<div class="card balance-card compact-balance-card">
|
||||
<p class="eyebrow">Current balance</p>
|
||||
<h1 class="balance-value">{{ money $page.User.BalanceMinor }}</h1>
|
||||
<p class="meta-line">Account number: <strong>{{ $page.User.AccountNumber }}</strong></p>
|
||||
<p class="meta-line">User: {{ $page.User.Username }}</p>
|
||||
<div class="actions">
|
||||
<a class="button primary" href="/app/transfer">Send money</a>
|
||||
<a class="button secondary" href="/app/transactions">Full history</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Recent activity</h2>
|
||||
{{ template "transaction_table" $page.Transactions }}
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "leaderboard" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="panel wide">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h1>Leaderboard</h1>
|
||||
<p class="muted">All active users ranked by current account balance.</p>
|
||||
</div>
|
||||
<div class="leaderboard-note">Ordered highest to lowest balance.</div>
|
||||
</div>
|
||||
<table class="table leaderboard-table">
|
||||
<thead><tr><th>#</th><th>User</th><th>Account</th><th>Balance</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range $page.Entries }}
|
||||
<tr>
|
||||
<td><span class="rank-pill">{{ .Rank }}</span></td>
|
||||
<td>{{ .Username }}</td>
|
||||
<td>{{ .AccountNumber }}</td>
|
||||
<td>{{ money .BalanceMinor }}</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="4" class="muted">No users available yet.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "transfer" }}
|
||||
<section class="panel medium">
|
||||
<h1>Send money</h1>
|
||||
<form method="post" action="/app/transfer" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<label>Destination account number<input type="text" name="account_number" inputmode="numeric" required></label>
|
||||
<label>Amount in CZK<input type="text" name="amount" placeholder="250,00" required></label>
|
||||
<label>Reference note<input type="text" name="description" maxlength="120"></label>
|
||||
<button class="button primary" type="submit">Send transfer</button>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "transactions" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="panel wide">
|
||||
<h1>Transaction history</h1>
|
||||
{{ template "transaction_table" $page.Transactions }}
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_dashboard" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="stack-section">
|
||||
<div class="stats-row">
|
||||
<article class="card stat-box"><span>Users</span><strong>{{ $page.Dashboard.UserCount }}</strong></article>
|
||||
<article class="card stat-box"><span>Accounts</span><strong>{{ $page.Dashboard.AccountCount }}</strong></article>
|
||||
<article class="card stat-box"><span>Transactions</span><strong>{{ $page.Dashboard.TransactionCount }}</strong></article>
|
||||
<article class="card stat-box"><span>Frozen users</span><strong>{{ $page.Dashboard.FrozenUserCount }}</strong></article>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Recent transactions</h2>
|
||||
{{ template "transaction_table" $page.Dashboard.RecentTransactions }}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Recent audit activity</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Admin</th><th>Action</th><th>Target</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range $page.Dashboard.RecentAuditActivity }}
|
||||
<tr><td>{{ dt .CreatedAt }}</td><td>{{ .AdminEmail }}</td><td>{{ .Action }}</td><td>{{ .TargetType }} / {{ .TargetID }}</td></tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="4" class="muted">No audit activity yet.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_users" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="panel wide">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p class="muted">Search by email, username, or account number.</p>
|
||||
</div>
|
||||
<form method="get" action="/admin/users" class="search-form">
|
||||
<input type="text" name="q" value="{{ $page.Search }}" placeholder="Search users">
|
||||
<button class="button secondary" type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>User</th><th>Account</th><th>Status</th><th>Balance</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range $page.Users }}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ .ID }}">{{ .Username }}</a><div class="subtle">{{ .Email }}</div></td>
|
||||
<td>{{ .AccountNumber }}</td>
|
||||
<td>{{ .Status }}</td>
|
||||
<td>{{ money .BalanceMinor }}</td>
|
||||
<td>{{ dt .CreatedAt }}</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="5" class="muted">No users found.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_user_detail" }}
|
||||
{{ $page := .Data }}
|
||||
<section class="stack-section">
|
||||
<div class="page-grid">
|
||||
<div class="card">
|
||||
<h1>{{ $page.User.Username }}</h1>
|
||||
<p class="meta-line">{{ $page.User.Email }}</p>
|
||||
<p class="meta-line">Account number: <strong>{{ $page.User.AccountNumber }}</strong></p>
|
||||
<p class="meta-line">Balance: <strong>{{ money $page.User.BalanceMinor }}</strong></p>
|
||||
<p class="meta-line">Status: <strong>{{ $page.User.Status }}</strong></p>
|
||||
<div class="actions">
|
||||
{{ if eq $page.User.Status "active" }}
|
||||
<form method="post" action="/admin/users/{{ $page.User.ID }}/freeze" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<button class="button danger" type="submit">Freeze user</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<form method="post" action="/admin/users/{{ $page.User.ID }}/unfreeze" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<button class="button secondary" type="submit">Unfreeze user</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Adjust balance</h2>
|
||||
<form method="post" action="/admin/accounts/{{ $page.User.AccountID }}/adjust" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<label>Direction
|
||||
<select name="direction">
|
||||
<option value="credit">Credit</option>
|
||||
<option value="debit">Debit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Amount<input type="text" name="amount" placeholder="500,00" required></label>
|
||||
<label>Reason<input type="text" name="reason" maxlength="150" required></label>
|
||||
<button class="button primary" type="submit">Apply adjustment</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Recent transactions</h2>
|
||||
{{ template "transaction_table" $page.Transactions }}
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "admin_audit_logs" }}
|
||||
<section class="panel wide">
|
||||
<h1>Audit logs</h1>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Admin</th><th>Action</th><th>Target</th><th>Metadata</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range .Data }}
|
||||
<tr>
|
||||
<td>{{ dt .CreatedAt }}</td>
|
||||
<td>{{ .AdminEmail }}</td>
|
||||
<td>{{ .Action }}</td>
|
||||
<td>{{ .TargetType }} / {{ .TargetID }}</td>
|
||||
<td><code>{{ .Metadata }}</code></td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="5" class="muted">No audit logs yet.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "transaction_table" }}
|
||||
<div class="table-wrap">
|
||||
<table class="table transaction-table">
|
||||
<thead><tr><th>When</th><th>Type</th><th>Direction</th><th>Sender</th><th>Recipient</th><th>Amount</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range . }}
|
||||
<tr>
|
||||
<td>{{ dt .CreatedAt }}</td>
|
||||
<td>{{ .Type }}</td>
|
||||
<td>{{ .Direction }}</td>
|
||||
<td>{{ if .SenderAccount }}{{ .SenderAccount }}{{ else }}-{{ end }}</td>
|
||||
<td>{{ if .RecipientAccount }}{{ .RecipientAccount }}{{ else }}-{{ end }}</td>
|
||||
<td>{{ money .AmountMinor }}</td>
|
||||
<td>{{ .Description }}</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="7" class="muted">No transactions yet.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue