mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
* feat: user labels poc
* feat: backend for transaction user labels
Add PATCH /api/transactions/:paymentHash/label that merges a
user-supplied {key:value} map into the existing transaction metadata
under the user_label key, preserving NIP-47 fields. Empty map clears
the labels. Trims whitespace, drops blank rows, caps key/value length.
Wire the frontend editor to call the endpoint and revalidate the
transactions SWR cache on success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: include user labels as columns in CSV export
Collect the union of user_label keys across all exported transactions
and emit each one as its own label_<key> column. The existing metadata
JSON column is preserved so importers like Raccoin keep working.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: drop hardcoded label suggestions, autocomplete from prior keys
Open the editor with a single blank row instead of four pre-seeded
fields. Suggest label keys via a datalist populated from any
user_label keys already present in the SWR transactions cache, so
suggestions reflect the user's own taxonomy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline label editor and indicator-only list row
Replace the nested label dialog with an inline editor that toggles
within the existing transaction detail dialog, removing dialog
stacking. In the transactions list, replace per-label badges with a
single tag icon next to the timestamp so row height stays uniform; the
full labels remain visible in the detail view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: don't bump updated_at on metadata-only edits
GORM's Update auto-touches updated_at, which made the transactions
list reorder labeled transactions to the top. Switch
SetTransactionMetadata to UpdateColumn so only the metadata column
changes. Add regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: route PATCH transaction label requests in wails
The desktop app routes API calls through WailsRequestRouter rather
than HTTP. Add a handler for PATCH /api/transactions/:hash/label
before the existing transaction lookup so labels work in Wails too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: finalize backend
* chore: rename to labels
* chore: finalize frontend
* chore: use tx id to add user labels
* chore: address minor nits
* fix: linting
* fix: use explicit primary key lookups
* chore: simplify transaction csv label export
* fix(frontend): drop label count from transaction list badge
The count adds visual weight without informing any decision from the
list view. The icon-as-badge already signals labels exist; the actual
values are in the details dialog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(frontend): revert transaction list label indicator to bare icon
The Badge wrapper made the icon-only indicator wider than tall, which
looked off. Restores the pre-PR look — a small TagIcon next to the
timestamp — since the in-dialog editor is the place to see actual labels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: im-adithya <imadithyavardhan@gmail.com>
164 lines
5.5 KiB
Go
164 lines
5.5 KiB
Go
package transactions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/datatypes"
|
|
|
|
"github.com/getAlby/hub/constants"
|
|
"github.com/getAlby/hub/db"
|
|
"github.com/getAlby/hub/tests"
|
|
)
|
|
|
|
func setupTransactionForUserLabels(t *testing.T, transactionType string, initialMetadata map[string]interface{}) (*transactionsService, *tests.TestService, *db.Transaction) {
|
|
t.Helper()
|
|
|
|
svc, err := tests.CreateTestService(t)
|
|
require.NoError(t, err)
|
|
|
|
preimage := tests.MockLNClientTransaction.Preimage
|
|
dbTransaction := &db.Transaction{
|
|
State: constants.TRANSACTION_STATE_SETTLED,
|
|
Type: transactionType,
|
|
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
|
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
|
Preimage: &preimage,
|
|
AmountMsat: 123000,
|
|
}
|
|
if initialMetadata != nil {
|
|
metadataBytes, err := json.Marshal(initialMetadata)
|
|
require.NoError(t, err)
|
|
dbTransaction.Metadata = datatypes.JSON(metadataBytes)
|
|
}
|
|
|
|
require.NoError(t, svc.DB.Create(dbTransaction).Error)
|
|
|
|
return NewTransactionsService(svc.DB, svc.EventPublisher), svc, dbTransaction
|
|
}
|
|
|
|
func loadTransactionMetadata(t *testing.T, svc *tests.TestService, id uint) map[string]interface{} {
|
|
t.Helper()
|
|
|
|
var dbTransaction db.Transaction
|
|
require.NoError(t, svc.DB.First(&dbTransaction, id).Error)
|
|
if dbTransaction.Metadata == nil {
|
|
return nil
|
|
}
|
|
|
|
metadata := map[string]interface{}{}
|
|
require.NoError(t, json.Unmarshal(dbTransaction.Metadata, &metadata))
|
|
return metadata
|
|
}
|
|
|
|
func TestSetTransactionUserLabels_ValidLabels(t *testing.T) {
|
|
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, nil)
|
|
defer svc.Remove()
|
|
|
|
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
|
|
"description": "top up PPQ.AI",
|
|
"counterparty": "PPQ.AI",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
|
|
labels, ok := metadata["user_labels"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "top up PPQ.AI", labels["description"])
|
|
assert.Equal(t, "PPQ.AI", labels["counterparty"])
|
|
}
|
|
|
|
func TestSetTransactionUserLabels_IncomingTransaction(t *testing.T) {
|
|
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_INCOMING, nil)
|
|
defer svc.Remove()
|
|
|
|
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
|
|
"source": "customer",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
|
|
labels, ok := metadata["user_labels"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "customer", labels["source"])
|
|
}
|
|
|
|
func TestSetTransactionUserLabels_PreservesExistingMetadata(t *testing.T) {
|
|
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, map[string]interface{}{
|
|
"comment": "hello",
|
|
"nostr": map[string]interface{}{"pubkey": "abcdef"},
|
|
})
|
|
defer svc.Remove()
|
|
|
|
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
|
|
"account": "sponsoring",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
|
|
assert.Equal(t, "hello", metadata["comment"])
|
|
assert.NotNil(t, metadata["nostr"])
|
|
labels, ok := metadata["user_labels"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "sponsoring", labels["account"])
|
|
}
|
|
|
|
func TestSetTransactionUserLabels_ClearsLabels(t *testing.T) {
|
|
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, map[string]interface{}{
|
|
"comment": "hello",
|
|
"user_labels": map[string]interface{}{"description": "old"},
|
|
})
|
|
defer svc.Remove()
|
|
|
|
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{})
|
|
require.NoError(t, err)
|
|
|
|
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
|
|
_, exists := metadata["user_labels"]
|
|
assert.False(t, exists)
|
|
assert.Equal(t, "hello", metadata["comment"])
|
|
}
|
|
|
|
func TestSetTransactionUserLabels_TrimsAndDropsBlankLabels(t *testing.T) {
|
|
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, nil)
|
|
defer svc.Remove()
|
|
|
|
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
|
|
" account ": " sponsoring ",
|
|
"empty": "",
|
|
"": "orphan",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
|
|
labels, ok := metadata["user_labels"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Len(t, labels, 1)
|
|
assert.Equal(t, "sponsoring", labels["account"])
|
|
}
|
|
|
|
func TestSetTransactionUserLabels_RejectsOversizedMetadata(t *testing.T) {
|
|
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, nil)
|
|
defer svc.Remove()
|
|
|
|
labels := map[string]string{
|
|
"description": strings.Repeat("a", constants.INVOICE_METADATA_MAX_LENGTH),
|
|
}
|
|
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, labels)
|
|
require.Error(t, err)
|
|
|
|
encodedMetadata, marshalErr := json.Marshal(map[string]interface{}{
|
|
"user_labels": labels,
|
|
})
|
|
require.NoError(t, marshalErr)
|
|
|
|
assert.Equal(t,
|
|
fmt.Sprintf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(encodedMetadata)),
|
|
err.Error(),
|
|
)
|
|
}
|