From b1bd7c25872f2558f49d2e7c462bcdb7504bc2ca Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Thu, 9 Apr 2026 16:26:45 +0530 Subject: [PATCH] feat: track last settled transaction time for apps (#2214) * feat: track last settled transaction time for apps * fix: use default subwallet ordering and app_id logging in event handler * chore: rename to last_settled_transaction_at and remove last tx migration * chore: split app settlement update and budget check * chore: extract app display name helper into utils function --- api/api.go | 75 ++++++++++--------- api/models.go | 35 ++++----- ...2604081200_app_last_settled_transaction.go | 29 +++++++ db/migrations/migrate.go | 1 + db/models.go | 21 +++--- frontend/src/components/TransactionItem.tsx | 11 +-- .../home/widgets/LatestUsedAppsWidget.tsx | 49 ++++++------ frontend/src/hooks/useApps.ts | 2 +- frontend/src/lib/utils.ts | 5 ++ frontend/src/screens/apps/AppDetails.tsx | 5 +- .../src/screens/subwallets/NewSubwallet.tsx | 11 +-- frontend/src/types.ts | 1 + nip47/event_handler.go | 2 +- transactions/transactions_service.go | 36 ++++++--- 14 files changed, 163 insertions(+), 120 deletions(-) create mode 100644 db/migrations/202604081200_app_last_settled_transaction.go diff --git a/api/api.go b/api/api.go index f252a061..4e31bed3 100644 --- a/api/api.go +++ b/api/api.go @@ -456,22 +456,23 @@ func (api *api) GetApp(dbApp *db.App) (*App, error) { } response := App{ - ID: dbApp.ID, - Name: dbApp.Name, - Description: dbApp.Description, - CreatedAt: dbApp.CreatedAt, - UpdatedAt: dbApp.UpdatedAt, - AppPubkey: dbApp.AppPubkey, - ExpiresAt: expiresAt, - MaxAmountSat: maxAmount, - Scopes: requestMethods, - BudgetUsage: budgetUsage / 1000, - BudgetRenewal: paySpecificPermission.BudgetRenewal, - Isolated: dbApp.Isolated, - Metadata: metadata, - WalletPubkey: walletPubkey, - UniqueWalletPubkey: uniqueWalletPubkey, - LastUsedAt: dbApp.LastUsedAt, + ID: dbApp.ID, + Name: dbApp.Name, + Description: dbApp.Description, + CreatedAt: dbApp.CreatedAt, + UpdatedAt: dbApp.UpdatedAt, + AppPubkey: dbApp.AppPubkey, + ExpiresAt: expiresAt, + MaxAmountSat: maxAmount, + Scopes: requestMethods, + BudgetUsage: budgetUsage / 1000, + BudgetRenewal: paySpecificPermission.BudgetRenewal, + Isolated: dbApp.Isolated, + Metadata: metadata, + WalletPubkey: walletPubkey, + UniqueWalletPubkey: uniqueWalletPubkey, + LastUsedAt: dbApp.LastUsedAt, + LastSettledTransactionAt: dbApp.LastSettledTransactionAt, } if dbApp.Isolated { @@ -525,15 +526,7 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o } } - if orderBy == "" { - orderBy = "last_used_at" - } - if orderBy == "last_used_at" { - // when ordering by last used at, apps with last_used_at is NULL should be ordered last - orderBy = "last_used_at IS NULL, " + orderBy - } - - query = query.Order(orderBy + " DESC") + query = query.Order(resolveAppOrderBy(orderBy)) if limit == 0 { limit = 100 @@ -590,16 +583,17 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o uniqueWalletPubkey = true } apiApp := App{ - ID: dbApp.ID, - Name: dbApp.Name, - Description: dbApp.Description, - CreatedAt: dbApp.CreatedAt, - UpdatedAt: dbApp.UpdatedAt, - AppPubkey: dbApp.AppPubkey, - Isolated: dbApp.Isolated, - WalletPubkey: walletPubkey, - UniqueWalletPubkey: uniqueWalletPubkey, - LastUsedAt: dbApp.LastUsedAt, + ID: dbApp.ID, + Name: dbApp.Name, + Description: dbApp.Description, + CreatedAt: dbApp.CreatedAt, + UpdatedAt: dbApp.UpdatedAt, + AppPubkey: dbApp.AppPubkey, + Isolated: dbApp.Isolated, + WalletPubkey: walletPubkey, + UniqueWalletPubkey: uniqueWalletPubkey, + LastUsedAt: dbApp.LastUsedAt, + LastSettledTransactionAt: dbApp.LastSettledTransactionAt, } if dbApp.Isolated { @@ -650,6 +644,17 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o }, nil } +func resolveAppOrderBy(orderBy string) string { + switch orderBy { + case "created_at": + return "created_at DESC" + case "last_settled_transaction": + return "last_settled_transaction_at IS NULL, last_settled_transaction_at DESC" + default: + return "last_used_at IS NULL, last_used_at DESC" + } +} + func (api *api) ListChannels(ctx context.Context) ([]Channel, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { diff --git a/api/models.go b/api/models.go index c6350a69..1adabd7a 100644 --- a/api/models.go +++ b/api/models.go @@ -89,23 +89,24 @@ type API interface { var ErrLNClientNotStarted = errors.New("LNClient not started") type App struct { - ID uint `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - AppPubkey string `json:"appPubkey"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - LastUsedAt *time.Time `json:"lastUsedAt"` - ExpiresAt *time.Time `json:"expiresAt"` - Scopes []string `json:"scopes"` - MaxAmountSat uint64 `json:"maxAmount"` - BudgetUsage uint64 `json:"budgetUsage"` - BudgetRenewal string `json:"budgetRenewal"` - Isolated bool `json:"isolated"` - WalletPubkey string `json:"walletPubkey"` - UniqueWalletPubkey bool `json:"uniqueWalletPubkey"` - Balance int64 `json:"balance"` - Metadata Metadata `json:"metadata,omitempty"` + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AppPubkey string `json:"appPubkey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastUsedAt *time.Time `json:"lastUsedAt"` + LastSettledTransactionAt *time.Time `json:"lastSettledTransactionAt"` + ExpiresAt *time.Time `json:"expiresAt"` + Scopes []string `json:"scopes"` + MaxAmountSat uint64 `json:"maxAmount"` + BudgetUsage uint64 `json:"budgetUsage"` + BudgetRenewal string `json:"budgetRenewal"` + Isolated bool `json:"isolated"` + WalletPubkey string `json:"walletPubkey"` + UniqueWalletPubkey bool `json:"uniqueWalletPubkey"` + Balance int64 `json:"balance"` + Metadata Metadata `json:"metadata,omitempty"` } type ListAppsFilters struct { diff --git a/db/migrations/202604081200_app_last_settled_transaction.go b/db/migrations/202604081200_app_last_settled_transaction.go new file mode 100644 index 00000000..f6e57b7b --- /dev/null +++ b/db/migrations/202604081200_app_last_settled_transaction.go @@ -0,0 +1,29 @@ +package migrations + +import ( + _ "embed" + "text/template" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +const appLastSettledTransactionMigration = `ALTER TABLE apps ADD COLUMN last_settled_transaction_at {{ .Timestamp }};` + +var appLastSettledTransactionMigrationTmpl = template.Must(template.New("appLastSettledTransactionMigration").Parse(appLastSettledTransactionMigration)) + +var _202604081200_app_last_settled_transaction = &gormigrate.Migration{ + ID: "202604081200_app_last_settled_transaction", + Migrate: func(tx *gorm.DB) error { + + err := exec(tx, appLastSettledTransactionMigrationTmpl) + if err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/db/migrations/migrate.go b/db/migrations/migrate.go index 6985149a..89158a40 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -38,6 +38,7 @@ func Migrate(gormDB *gorm.DB) error { _202508151405_swap_xpub, _202508192137_forwards, _202509031250_transactions_updated_at_index, + _202604081200_app_last_settled_transaction, }) return m.Migrate() diff --git a/db/models.go b/db/models.go index 65567f3a..fd956094 100644 --- a/db/models.go +++ b/db/models.go @@ -16,16 +16,17 @@ type UserConfig struct { } type App struct { - ID uint - Name string `validate:"required"` - Description string - AppPubkey string `validate:"required"` - WalletPubkey *string - CreatedAt time.Time - UpdatedAt time.Time - LastUsedAt *time.Time - Isolated bool - Metadata datatypes.JSON + ID uint + Name string `validate:"required"` + Description string + AppPubkey string `validate:"required"` + WalletPubkey *string + CreatedAt time.Time + UpdatedAt time.Time + LastUsedAt *time.Time + LastSettledTransactionAt *time.Time + Isolated bool + Metadata datatypes.JSON } type AppPermission struct { diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index 3e0cdba8..f770f1fd 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -27,11 +27,10 @@ import { DialogTitle, DialogTrigger, } from "src/components/ui/dialog"; -import { ALBY_ACCOUNT_APP_NAME } from "src/constants"; import { useApp } from "src/hooks/useApp"; import { useSwap } from "src/hooks/useSwaps"; import { copyToClipboard } from "src/lib/clipboard"; -import { cn } from "src/lib/utils"; +import { cn, getAppDisplayName } from "src/lib/utils"; import { Transaction } from "src/types"; dayjs.extend(utc); @@ -154,7 +153,7 @@ function TransactionItem({ tx }: Props) { {app && (

App

-

- {app.name === ALBY_ACCOUNT_APP_NAME - ? "Alby Account" - : app.name} -

+

{getAppDisplayName(app.name)}

)} diff --git a/frontend/src/components/home/widgets/LatestUsedAppsWidget.tsx b/frontend/src/components/home/widgets/LatestUsedAppsWidget.tsx index 079fea3d..07351f35 100644 --- a/frontend/src/components/home/widgets/LatestUsedAppsWidget.tsx +++ b/frontend/src/components/home/widgets/LatestUsedAppsWidget.tsx @@ -9,13 +9,18 @@ import { CardTitle, } from "src/components/ui/card"; import { LinkButton } from "src/components/ui/custom/link-button"; -import { ALBY_ACCOUNT_APP_NAME } from "src/constants"; import { useApps } from "src/hooks/useApps"; +import { getAppDisplayName } from "src/lib/utils"; export function LatestUsedAppsWidget() { - const { data: appsData } = useApps(3, undefined, undefined, "last_used_at"); + const { data: appsData } = useApps( + 3, + undefined, + undefined, + "last_settled_transaction" + ); const apps = appsData?.apps; - const usedApps = apps?.filter((x) => x.lastUsedAt); + const usedApps = apps?.filter((x) => x.lastSettledTransactionAt); if (!usedApps?.length) { return null; @@ -32,28 +37,22 @@ export function LatestUsedAppsWidget() { - {usedApps - .sort( - (a, b) => - new Date(b.lastUsedAt ?? 0).getTime() - - new Date(a.lastUsedAt ?? 0).getTime() - ) - .map((app) => ( - -
- -

- {app.name === ALBY_ACCOUNT_APP_NAME - ? "Alby Account" - : app.name} -

-

- {app.lastUsedAt ? dayjs(app.lastUsedAt).fromNow() : "never"} -

- -
- - ))} + {usedApps.map((app) => ( + +
+ +

+ {getAppDisplayName(app.name)} +

+

+ {app.lastSettledTransactionAt + ? dayjs(app.lastSettledTransactionAt).fromNow() + : "never"} +

+ +
+ + ))}
); diff --git a/frontend/src/hooks/useApps.ts b/frontend/src/hooks/useApps.ts index 4c551b74..dbfe3f93 100644 --- a/frontend/src/hooks/useApps.ts +++ b/frontend/src/hooks/useApps.ts @@ -15,7 +15,7 @@ export function useApps( unused?: boolean; subWallets?: boolean; }, - orderBy?: "last_used_at" | "created_at", + orderBy?: "last_used_at" | "last_settled_transaction" | "created_at", isEnabled = true ) { const offset = (page - 1) * limit; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 4cb60100..6233b817 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,4 +1,5 @@ import { clsx, type ClassValue } from "clsx"; +import { ALBY_ACCOUNT_APP_NAME } from "src/constants"; import { BudgetRenewalType } from "src/types"; import { twMerge } from "tailwind-merge"; @@ -81,3 +82,7 @@ export function getBudgetRenewalLabel(renewalType: BudgetRenewalType): string { return ""; } } + +export function getAppDisplayName(name: string) { + return name === ALBY_ACCOUNT_APP_NAME ? "Alby Account" : name; +} diff --git a/frontend/src/screens/apps/AppDetails.tsx b/frontend/src/screens/apps/AppDetails.tsx index dd817390..3acdd21a 100644 --- a/frontend/src/screens/apps/AppDetails.tsx +++ b/frontend/src/screens/apps/AppDetails.tsx @@ -68,7 +68,7 @@ import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useApp } from "src/hooks/useApp"; import { useAppsForAppStoreApp } from "src/hooks/useApps"; import { useCapabilities } from "src/hooks/useCapabilities"; -import { cn } from "src/lib/utils"; +import { cn, getAppDisplayName } from "src/lib/utils"; function AppDetails() { const { id } = useParams() as { id: string }; @@ -181,8 +181,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { } }; - const appName = - app.name === ALBY_ACCOUNT_APP_NAME ? "Alby Account" : app.name; + const appName = getAppDisplayName(app.name); const appStoreApp = getAppStoreApp(app); const connectedApps = useAppsForAppStoreApp(appStoreApp); diff --git a/frontend/src/screens/subwallets/NewSubwallet.tsx b/frontend/src/screens/subwallets/NewSubwallet.tsx index cd117ade..db603e7b 100644 --- a/frontend/src/screens/subwallets/NewSubwallet.tsx +++ b/frontend/src/screens/subwallets/NewSubwallet.tsx @@ -19,14 +19,9 @@ import { handleRequestError } from "src/utils/handleRequestError"; export function NewSubwallet() { const navigate = useNavigate(); const [name, setName] = React.useState(""); - const { data: subwalletAppsData } = useApps( - undefined, - undefined, - { - subWallets: true, - }, - "created_at" - ); + const { data: subwalletAppsData } = useApps(undefined, undefined, { + subWallets: true, + }); const { data: info } = useInfo(); const { data: albyMe, error: albyMeError } = useAlbyMe(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ded3297d..f39565b5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -120,6 +120,7 @@ export interface App { createdAt: string; updatedAt: string; lastUsedAt?: string; + lastSettledTransactionAt?: string; expiresAt?: string; isolated: boolean; balance: number; diff --git a/nip47/event_handler.go b/nip47/event_handler.go index c765d74d..948bc6f2 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -85,7 +85,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, pool nostrmodels.Simpl err = svc.db.Model(&app).Update("last_used_at", &now).Error if err != nil { logger.Logger.WithFields(logrus.Fields{ - "it": app.ID, + "app_id": app.ID, }).WithError(err).Error("Failed to update app last used time") } diff --git a/transactions/transactions_service.go b/transactions/transactions_service.go index 7f8edff9..06781565 100644 --- a/transactions/transactions_service.go +++ b/transactions/transactions_service.go @@ -1404,13 +1404,13 @@ func (svc *transactionsService) markTransactionSettled(tx *gorm.DB, dbTransactio return &existingSettledTransaction, nil } - now := time.Now() + settledAt := time.Now() err := tx.Model(dbTransaction).Updates(map[string]interface{}{ "State": constants.TRANSACTION_STATE_SETTLED, "Preimage": &preimage, "FeeMsat": fee, "FeeReserveMsat": 0, - "SettledAt": &now, + "SettledAt": &settledAt, "SelfPayment": selfPayment, }).Error if err != nil { @@ -1435,28 +1435,40 @@ func (svc *transactionsService) markTransactionSettled(tx *gorm.DB, dbTransactio Properties: dbTransaction, }) - if dbTransaction.Type == constants.TRANSACTION_TYPE_OUTGOING && dbTransaction.AppId != nil { - svc.checkBudgetUsage(dbTransaction, tx) + if dbTransaction.AppId != nil { + var app db.App + result := tx.Limit(1).Find(&app, &db.App{ + ID: *dbTransaction.AppId, + }) + if result.RowsAffected == 0 { + logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find app by id") + return dbTransaction, nil + } + + svc.updateAppLastSettledTransactionAt(&app, tx, &settledAt) + + if dbTransaction.Type == constants.TRANSACTION_TYPE_OUTGOING { + svc.checkBudgetUsage(&app, dbTransaction, tx) + } } return dbTransaction, nil } -func (svc *transactionsService) checkBudgetUsage(dbTransaction *db.Transaction, gormTransaction *gorm.DB) { - var app db.App - result := gormTransaction.Limit(1).Find(&app, &db.App{ - ID: *dbTransaction.AppId, - }) - if result.RowsAffected == 0 { - logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find app by id") +func (svc *transactionsService) updateAppLastSettledTransactionAt(app *db.App, gormTransaction *gorm.DB, settledAt *time.Time) { + if err := gormTransaction.Model(app).Update("last_settled_transaction_at", settledAt).Error; err != nil { + logger.Logger.WithField("app_id", app.ID).WithError(err).Error("failed to update app last settled transaction time") return } +} + +func (svc *transactionsService) checkBudgetUsage(app *db.App, dbTransaction *db.Transaction, gormTransaction *gorm.DB) { if app.Isolated { return } var appPermission db.AppPermission - result = gormTransaction.Limit(1).Find(&appPermission, &db.AppPermission{ + result := gormTransaction.Limit(1).Find(&appPermission, &db.AppPermission{ AppId: app.ID, Scope: constants.PAY_INVOICE_SCOPE, })