mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
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
This commit is contained in:
parent
18efdcdf11
commit
b1bd7c2587
14 changed files with 163 additions and 120 deletions
75
api/api.go
75
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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
29
db/migrations/202604081200_app_last_settled_transaction.go
Normal file
29
db/migrations/202604081200_app_last_settled_transaction.go
Normal file
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
21
db/models.go
21
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 {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1"
|
||||
title={`${typeStateText} via ${app.name === ALBY_ACCOUNT_APP_NAME ? "Alby Account" : app.name}`}
|
||||
title={`${typeStateText} via ${getAppDisplayName(app.name)}`}
|
||||
>
|
||||
<AppAvatar
|
||||
app={app}
|
||||
|
|
@ -244,11 +243,7 @@ function TransactionItem({ tx }: Props) {
|
|||
<div className="mt-8">
|
||||
<p>App</p>
|
||||
<Link to={`/apps/${app.id}`}>
|
||||
<p className="font-semibold">
|
||||
{app.name === ALBY_ACCOUNT_APP_NAME
|
||||
? "Alby Account"
|
||||
: app.name}
|
||||
</p>
|
||||
<p className="font-semibold">{getAppDisplayName(app.name)}</p>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4">
|
||||
{usedApps
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastUsedAt ?? 0).getTime() -
|
||||
new Date(a.lastUsedAt ?? 0).getTime()
|
||||
)
|
||||
.map((app) => (
|
||||
<Link key={app.id} to={`/apps/${app.id}`}>
|
||||
<div className="flex items-center w-full gap-4">
|
||||
<AppAvatar app={app} className="w-14 h-14 rounded-lg" />
|
||||
<p className="text-sm font-medium flex-1 truncate">
|
||||
{app.name === ALBY_ACCOUNT_APP_NAME
|
||||
? "Alby Account"
|
||||
: app.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{app.lastUsedAt ? dayjs(app.lastUsedAt).fromNow() : "never"}
|
||||
</p>
|
||||
<ChevronRightIcon className="text-muted-foreground size-8" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{usedApps.map((app) => (
|
||||
<Link key={app.id} to={`/apps/${app.id}`}>
|
||||
<div className="flex items-center w-full gap-4">
|
||||
<AppAvatar app={app} className="w-14 h-14 rounded-lg" />
|
||||
<p className="text-sm font-medium flex-1 truncate">
|
||||
{getAppDisplayName(app.name)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{app.lastSettledTransactionAt
|
||||
? dayjs(app.lastSettledTransactionAt).fromNow()
|
||||
: "never"}
|
||||
</p>
|
||||
<ChevronRightIcon className="text-muted-foreground size-8" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export interface App {
|
|||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string;
|
||||
lastSettledTransactionAt?: string;
|
||||
expiresAt?: string;
|
||||
isolated: boolean;
|
||||
balance: number;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue