feat: add localized ledger explorer dashboard
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
// Package explorer implements the read-only queries used by GL's web explorer.
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
const (
|
||||
recentJournalLimit = 12
|
||||
transactionPageSize = 20
|
||||
topAssetLimit = 5
|
||||
topHoldersPerAsset = 5
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
JournalCount int64
|
||||
EntryCount int64
|
||||
AccountCount int64
|
||||
LastRecordedAt time.Time
|
||||
}
|
||||
|
||||
type Holder struct {
|
||||
Rank int64
|
||||
OwnerType string
|
||||
OwnerID string
|
||||
AssetID int64
|
||||
Balance ledger.Amount
|
||||
}
|
||||
|
||||
type HolderReference struct {
|
||||
OwnerType string
|
||||
OwnerID string
|
||||
AssetID int64
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Stats(context.Context) (Stats, error)
|
||||
List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error)
|
||||
GetByTransactionHash(context.Context, string) ([]ledger.Journal, error)
|
||||
GetByID(context.Context, string) (ledger.Journal, error)
|
||||
Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error)
|
||||
TopHolders(context.Context, int, int) ([]Holder, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
Stats Stats
|
||||
Journals []ledger.Journal
|
||||
}
|
||||
|
||||
type Assets struct {
|
||||
TopHolders []Holder
|
||||
}
|
||||
|
||||
type TransactionListing struct {
|
||||
Journals []ledger.Journal
|
||||
Filter TransactionFilter
|
||||
HasPrevious bool
|
||||
HasNext bool
|
||||
}
|
||||
|
||||
type TransactionFilter struct {
|
||||
Page int
|
||||
Wallet string
|
||||
EffectKind string
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Reference ledger.AccountReference
|
||||
Balance ledger.Amount
|
||||
Journals []ledger.Journal
|
||||
}
|
||||
|
||||
type HolderAccount struct {
|
||||
Reference HolderReference
|
||||
Balance ledger.Amount
|
||||
Journals []ledger.Journal
|
||||
}
|
||||
|
||||
func NewService(repository Repository) *Service {
|
||||
return &Service{repository: repository}
|
||||
}
|
||||
|
||||
func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) {
|
||||
stats, err := s.repository.Stats(ctx)
|
||||
if err != nil {
|
||||
return Dashboard{}, fmt.Errorf("read explorer stats: %w", err)
|
||||
}
|
||||
journals, err := s.repository.List(ctx, ledger.JournalFilter{Limit: recentJournalLimit})
|
||||
if err != nil {
|
||||
return Dashboard{}, fmt.Errorf("read recent journals: %w", err)
|
||||
}
|
||||
return Dashboard{Stats: stats, Journals: journals}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Assets(ctx context.Context) (Assets, error) {
|
||||
holders, err := s.repository.TopHolders(ctx, topAssetLimit, topHoldersPerAsset)
|
||||
if err != nil {
|
||||
return Assets{}, fmt.Errorf("read top asset holders: %w", err)
|
||||
}
|
||||
return Assets{TopHolders: holders}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Transactions(ctx context.Context, filter TransactionFilter) (TransactionListing, error) {
|
||||
filter.Wallet = strings.TrimSpace(filter.Wallet)
|
||||
filter.EffectKind = strings.TrimSpace(filter.EffectKind)
|
||||
if filter.Page < 1 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.Page > 1_000_000 {
|
||||
return TransactionListing{}, fmt.Errorf("transaction page is too large")
|
||||
}
|
||||
if len(filter.Wallet) > 256 || len(filter.EffectKind) > 128 {
|
||||
return TransactionListing{}, fmt.Errorf("transaction filter is too long")
|
||||
}
|
||||
repositoryFilter := ledger.JournalFilter{
|
||||
Limit: transactionPageSize + 1,
|
||||
Offset: (filter.Page - 1) * transactionPageSize,
|
||||
}
|
||||
if filter.Wallet != "" {
|
||||
repositoryFilter.OwnerID = &filter.Wallet
|
||||
}
|
||||
if filter.EffectKind != "" {
|
||||
repositoryFilter.EffectKind = &filter.EffectKind
|
||||
}
|
||||
journals, err := s.repository.List(ctx, repositoryFilter)
|
||||
if err != nil {
|
||||
return TransactionListing{}, fmt.Errorf("read transaction page: %w", err)
|
||||
}
|
||||
listing := TransactionListing{Journals: journals, Filter: filter, HasPrevious: filter.Page > 1}
|
||||
if len(listing.Journals) > transactionPageSize {
|
||||
listing.HasNext = true
|
||||
listing.Journals = listing.Journals[:transactionPageSize]
|
||||
}
|
||||
return listing, nil
|
||||
}
|
||||
|
||||
func (s *Service) Transaction(ctx context.Context, reference string) ([]ledger.Journal, error) {
|
||||
reference = strings.TrimSpace(reference)
|
||||
if reference == "" {
|
||||
return nil, fmt.Errorf("transaction reference is required")
|
||||
}
|
||||
if len(reference) > 256 {
|
||||
return nil, fmt.Errorf("transaction reference is too long")
|
||||
}
|
||||
journals, err := s.repository.GetByTransactionHash(ctx, reference)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read transaction: %w", err)
|
||||
}
|
||||
if len(journals) != 0 {
|
||||
return journals, nil
|
||||
}
|
||||
if !isJournalID(reference) {
|
||||
return nil, ledger.ErrNotFound
|
||||
}
|
||||
journal, err := s.repository.GetByID(ctx, reference)
|
||||
if err != nil {
|
||||
if errors.Is(err, ledger.ErrNotFound) {
|
||||
return nil, ledger.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("read internal transaction: %w", err)
|
||||
}
|
||||
return []ledger.Journal{journal}, nil
|
||||
}
|
||||
|
||||
func isJournalID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
switch index {
|
||||
case 8, 13, 18, 23:
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) Account(ctx context.Context, reference ledger.AccountReference) (Account, error) {
|
||||
if err := reference.Validate(); err != nil {
|
||||
return Account{}, fmt.Errorf("invalid account: %w", err)
|
||||
}
|
||||
balance, err := s.repository.Balance(ctx, reference, time.Time{})
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("read account balance: %w", err)
|
||||
}
|
||||
journals, err := s.repository.List(ctx, ledger.JournalFilter{
|
||||
Account: &reference,
|
||||
AssetID: &reference.AssetID,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("read account journals: %w", err)
|
||||
}
|
||||
return Account{Reference: reference, Balance: balance, Journals: journals}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Holder(ctx context.Context, reference HolderReference) (HolderAccount, error) {
|
||||
if strings.TrimSpace(reference.OwnerType) == "" || strings.TrimSpace(reference.OwnerID) == "" || reference.AssetID <= 0 {
|
||||
return HolderAccount{}, fmt.Errorf("invalid holder reference")
|
||||
}
|
||||
|
||||
result := HolderAccount{Reference: reference}
|
||||
seen := make(map[string]struct{})
|
||||
for _, class := range []ledger.AccountClass{ledger.AccountClassUserAvailable, ledger.AccountClassUserFrozen} {
|
||||
account := ledger.AccountReference{Class: class, OwnerType: reference.OwnerType, OwnerID: reference.OwnerID, AssetID: reference.AssetID}
|
||||
balance, err := s.repository.Balance(ctx, account, time.Time{})
|
||||
if err != nil {
|
||||
return HolderAccount{}, fmt.Errorf("read holder balance: %w", err)
|
||||
}
|
||||
result.Balance = result.Balance.Add(balance)
|
||||
|
||||
journals, err := s.repository.List(ctx, ledger.JournalFilter{Account: &account, AssetID: &reference.AssetID, Limit: 50})
|
||||
if err != nil {
|
||||
return HolderAccount{}, fmt.Errorf("read holder journals: %w", err)
|
||||
}
|
||||
for _, journal := range journals {
|
||||
if _, exists := seen[journal.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[journal.ID] = struct{}{}
|
||||
result.Journals = append(result.Journals, journal)
|
||||
}
|
||||
}
|
||||
sort.Slice(result.Journals, func(left, right int) bool {
|
||||
if result.Journals[left].RecordedAt.Equal(result.Journals[right].RecordedAt) {
|
||||
return result.Journals[left].ID > result.Journals[right].ID
|
||||
}
|
||||
return result.Journals[left].RecordedAt.After(result.Journals[right].RecordedAt)
|
||||
})
|
||||
if len(result.Journals) > 50 {
|
||||
result.Journals = result.Journals[:50]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, ledger.ErrNotFound)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type repositoryStub struct {
|
||||
stats Stats
|
||||
journals []ledger.Journal
|
||||
transactions []ledger.Journal
|
||||
journal ledger.Journal
|
||||
journalErr error
|
||||
holders []Holder
|
||||
balance ledger.Amount
|
||||
lastFilter *ledger.JournalFilter
|
||||
}
|
||||
|
||||
func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil }
|
||||
func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) {
|
||||
if r.lastFilter != nil {
|
||||
*r.lastFilter = filter
|
||||
}
|
||||
return r.journals, nil
|
||||
}
|
||||
func (r repositoryStub) GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) {
|
||||
return r.transactions, nil
|
||||
}
|
||||
func (r repositoryStub) GetByID(context.Context, string) (ledger.Journal, error) {
|
||||
return r.journal, r.journalErr
|
||||
}
|
||||
func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) {
|
||||
return r.balance, nil
|
||||
}
|
||||
func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) {
|
||||
return r.holders, nil
|
||||
}
|
||||
|
||||
func TestAssetsIncludesTopHolders(t *testing.T) {
|
||||
balance, err := ledger.ParseAmount("125.5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(repositoryStub{holders: []Holder{{Rank: 1, OwnerType: "user", OwnerID: "42", AssetID: 7, Balance: balance}}})
|
||||
|
||||
result, err := service.Assets(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.TopHolders) != 1 || result.TopHolders[0].Balance.String() != "125.5" {
|
||||
t.Fatalf("unexpected top holders: %+v", result.TopHolders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionsPaginatesWithLookahead(t *testing.T) {
|
||||
journals := make([]ledger.Journal, transactionPageSize+1)
|
||||
for index := range journals {
|
||||
journals[index].ID = fmt.Sprintf("journal-%d", index)
|
||||
}
|
||||
var repositoryFilter ledger.JournalFilter
|
||||
service := NewService(repositoryStub{journals: journals, lastFilter: &repositoryFilter})
|
||||
|
||||
result, err := service.Transactions(context.Background(), TransactionFilter{Page: 2, Wallet: " wallet-42 ", EffectKind: " transfer "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filter.Page != 2 || result.Filter.Wallet != "wallet-42" || result.Filter.EffectKind != "transfer" || !result.HasPrevious || !result.HasNext || len(result.Journals) != transactionPageSize {
|
||||
t.Fatalf("unexpected transaction page: %+v", result)
|
||||
}
|
||||
if repositoryFilter.OwnerID == nil || *repositoryFilter.OwnerID != "wallet-42" || repositoryFilter.EffectKind == nil || *repositoryFilter.EffectKind != "transfer" || repositoryFilter.Offset != transactionPageSize {
|
||||
t.Fatalf("unexpected repository filter: %+v", repositoryFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolderCombinesAvailableAndFrozenBalances(t *testing.T) {
|
||||
balance, err := ledger.ParseAmount("10.25")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}})
|
||||
|
||||
result, err := service.Holder(context.Background(), HolderReference{OwnerType: "user", OwnerID: "42", AssetID: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Balance.String() != "20.5" || len(result.Journals) != 1 {
|
||||
t.Fatalf("unexpected aggregate holder account: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionRequiresAResult(t *testing.T) {
|
||||
service := NewService(repositoryStub{})
|
||||
if _, err := service.Transaction(context.Background(), "hash"); !IsNotFound(err) {
|
||||
t.Fatalf("expected not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionFallsBackToInternalJournalID(t *testing.T) {
|
||||
const journalID = "5c1e31b0-0000-4000-8000-0000084accc8"
|
||||
service := NewService(repositoryStub{journal: ledger.Journal{ID: journalID}})
|
||||
|
||||
result, err := service.Transaction(context.Background(), journalID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].ID != journalID {
|
||||
t.Fatalf("unexpected internal transaction result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountReturnsBalanceAndHistory(t *testing.T) {
|
||||
balance, err := ledger.ParseAmount("12.5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reference := ledger.AccountReference{
|
||||
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "42", AssetID: 7,
|
||||
}
|
||||
service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}})
|
||||
result, err := service.Account(context.Background(), reference)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Balance.String() != "12.5" || len(result.Journals) != 1 {
|
||||
t.Fatalf("unexpected account result: %+v", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user