From d1aafc898404a8162229d0f9361fc30cf5f2d327 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Fri, 7 Mar 2025 17:42:00 -0600 Subject: [PATCH 1/6] Transaction List --- pkg/store/mock/store.go | 4 + pkg/store/models/transaction.go | 32 ++++++ pkg/store/sqlite/transactions.go | 50 +++++++++ pkg/store/store.go | 1 + pkg/web/pages.go | 18 +++- pkg/web/routes.go | 60 +++++++---- pkg/web/scene/scene.go | 10 ++ pkg/web/static/js/transactions/index.js | 25 +++++ pkg/web/sunrise.go | 4 +- pkg/web/templates/dashboard/send/sunrise.html | 4 + .../dashboard/transactions/list.html | 67 +++++++++++- .../templates/partials/transactions/list.html | 101 ++++++++++++++++++ pkg/web/transactions.go | 2 +- 13 files changed, 353 insertions(+), 25 deletions(-) create mode 100644 pkg/web/static/js/transactions/index.js create mode 100644 pkg/web/templates/dashboard/send/sunrise.html diff --git a/pkg/store/mock/store.go b/pkg/store/mock/store.go index ea67874d..4cba28c1 100644 --- a/pkg/store/mock/store.go +++ b/pkg/store/mock/store.go @@ -47,6 +47,10 @@ func (s *Store) ArchiveTransaction(context.Context, uuid.UUID) error { return nil } +func (s *Store) CountTransactions(context.Context) (*models.TransactionCounts, error) { + return nil, nil +} + func (s *Store) PrepareTransaction(context.Context, uuid.UUID) (models.PreparedTransaction, error) { return nil, nil } diff --git a/pkg/store/models/transaction.go b/pkg/store/models/transaction.go index 0f8d4e3e..fa5764f6 100644 --- a/pkg/store/models/transaction.go +++ b/pkg/store/models/transaction.go @@ -65,6 +65,11 @@ type Transaction struct { envelopes []*SecureEnvelope // Associated secure envelopes } +type TransactionCounts struct { + Active map[string]int // Active transaction counts by status + Archived map[string]int // Archived transaction counts by status +} + type SecureEnvelope struct { Model EnvelopeID uuid.UUID // Also a foreign key reference to the Transaction @@ -322,3 +327,30 @@ func (e *SecureEnvelope) Transaction() (*Transaction, error) { func (e *SecureEnvelope) SetTransaction(tx *Transaction) { e.transaction = tx } + +func (c *TransactionCounts) TotalActive() int { + var total int + for _, count := range c.Active { + total += count + } + return total +} + +func (c *TransactionCounts) TotalArchived() int { + var total int + for _, count := range c.Archived { + total += count + } + return total +} + +func (c *TransactionCounts) Total() int { + var total int + for _, count := range c.Active { + total += count + } + for _, count := range c.Archived { + total += count + } + return total +} diff --git a/pkg/store/sqlite/transactions.go b/pkg/store/sqlite/transactions.go index 3c01f0f5..ccdc1e94 100644 --- a/pkg/store/sqlite/transactions.go +++ b/pkg/store/sqlite/transactions.go @@ -204,6 +204,56 @@ func (s *Store) archiveTransaction(tx *sql.Tx, transactionID uuid.UUID) (err err return nil } +const countTransactionsSQL = "SELECT count(id), status FROM transactions WHERE archived=:archived GROUP BY status" + +func (s *Store) CountTransactions(ctx context.Context) (counts *models.TransactionCounts, err error) { + var tx *sql.Tx + if tx, err = s.BeginTx(ctx, nil); err != nil { + return nil, err + } + defer tx.Rollback() + + counts = &models.TransactionCounts{ + Active: make(map[string]int), + Archived: make(map[string]int), + } + + if err = s.countTransactions(tx, counts, false); err != nil { + return nil, err + } + + if err = s.countTransactions(tx, counts, true); err != nil { + return nil, err + } + + tx.Commit() + return counts, nil +} + +func (s *Store) countTransactions(tx *sql.Tx, counts *models.TransactionCounts, archived bool) (err error) { + var rows *sql.Rows + if rows, err = tx.Query(countTransactionsSQL, sql.Named("archived", archived)); err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var count int + var status string + if err = rows.Scan(&count, &status); err != nil { + return err + } + + if archived { + counts.Archived[status] = count + } else { + counts.Active[status] = count + } + } + + return rows.Err() +} + //=========================================================================== // Secure Envelopes CRUD Interface //=========================================================================== diff --git a/pkg/store/store.go b/pkg/store/store.go index 23745c6e..93968a4a 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -86,6 +86,7 @@ type TransactionStore interface { DeleteTransaction(context.Context, uuid.UUID) error ArchiveTransaction(context.Context, uuid.UUID) error PrepareTransaction(context.Context, uuid.UUID) (models.PreparedTransaction, error) + CountTransactions(context.Context) (*models.TransactionCounts, error) } // SecureEnvelopes are associated with individual transactions. diff --git a/pkg/web/pages.go b/pkg/web/pages.go index 5af7f38d..01c27afe 100644 --- a/pkg/web/pages.go +++ b/pkg/web/pages.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/rs/zerolog/log" "github.com/trisacrypto/envoy/pkg" "github.com/trisacrypto/envoy/pkg/web/api/v1" "github.com/trisacrypto/envoy/pkg/web/htmx" @@ -51,13 +52,28 @@ func (s *Server) ResetPasswordSuccessPage(c *gin.Context) { //=========================================================================== func (s *Server) TransactionsListPage(c *gin.Context) { - c.HTML(http.StatusOK, "dashboard/transactions/list.html", scene.New(c)) + // Count the number of transactions in the database (ignore errors) + counts, _ := s.store.CountTransactions(c.Request.Context()) + log.Warn().Str("counts", fmt.Sprintf("%+v", counts)).Msg("counts") + + ctx := scene.New(c).WithAPIData(counts) + ctx["Status"] = strings.ToLower(c.Query("status")) + + c.HTML(http.StatusOK, "dashboard/transactions/list.html", ctx) } func (s *Server) SendEnvelopeForm(c *gin.Context) { c.HTML(http.StatusOK, "dashboard/transactions/send.html", scene.New(c)) } +func (s *Server) SendTRISAForm(c *gin.Context) { + c.HTML(http.StatusOK, "dashboard/transactions/send.html", scene.New(c)) +} + +func (s *Server) SendTRPForm(c *gin.Context) { + c.HTML(http.StatusOK, "dashboard/transactions/send.html", scene.New(c)) +} + func (s *Server) TransactionsAcceptPreview(c *gin.Context) { // Get the transaction ID from the URL path and make available to the template. ctx := scene.New(c) diff --git a/pkg/web/routes.go b/pkg/web/routes.go index de6ec36f..4403e70d 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -92,26 +92,50 @@ func (s *Server) setupRoutes() (err error) { return auth.Authorize(perms.String()...) } - // Web UI Routes (Pages) - s.router.GET("/", authenticate, s.Home) + // Web UI Routes (Dashboards and Pages) - Unauthenticated s.router.GET("/login", s.LoginPage) s.router.GET("/logout", s.Logout) s.router.GET("/reset-password", s.ResetPasswordPage) s.router.GET("/reset-password/success", s.ResetPasswordSuccessPage) - s.router.GET("/about", authenticate, s.AboutPage) - s.router.GET("/settings", authenticate, s.SettingsPage) - s.router.GET("/profile", authenticate, s.UserProfile) - s.router.GET("/profile/account", authenticate, s.UserAccount) - s.router.GET("/transactions", authenticate, s.TransactionsListPage) - s.router.GET("/transactions/:id/accept", authenticate, s.TransactionsAcceptPreview) - s.router.GET("/transactions/:id/repair", authenticate, s.TransactionsRepairPreview) - s.router.GET("/transactions/:id/info", authenticate, s.TransactionDetailPage) - s.router.GET("/send-envelope", authenticate, s.SendEnvelopeForm) - s.router.GET("/accounts", authenticate, s.AccountsListPage) - s.router.GET("/counterparties", authenticate, s.CounterpartiesListPage) - s.router.GET("/users", authenticate, s.UsersListPage) - s.router.GET("/apikeys", authenticate, s.APIKeysListPage) - s.router.GET("/utilities/travel-address", authenticate, s.TravelAddressUtility) + + // Web UI Routes (Dashboards and Pages) - Authenticated + ui := s.router.Group("", authenticate) + { + ui.GET("/", s.Home) + ui.GET("/about", s.AboutPage) + ui.GET("/settings", s.SettingsPage) + ui.GET("/accounts", s.AccountsListPage) + ui.GET("/counterparties", s.CounterpartiesListPage) + ui.GET("/users", s.UsersListPage) + ui.GET("/apikeys", s.APIKeysListPage) + ui.GET("/utilities/travel-address", s.TravelAddressUtility) + + // Profile Pages + profile := ui.Group("/profile") + { + profile.GET("", s.UserProfile) + profile.GET("/account", s.UserAccount) + } + + // Transactions Pages + transactions := ui.Group("/transactions") + { + transactions.GET("", s.TransactionsListPage) + transactions.GET("/:id/accept", s.TransactionsAcceptPreview) + transactions.GET("/:id/repair", s.TransactionsRepairPreview) + transactions.GET("/:id/info", s.TransactionDetailPage) + } + + // Send Secure Message Forms + send := ui.Group("/send", authorize(permiss.TravelRuleManage)) + { + send.GET("", s.SendEnvelopeForm) + send.GET("/trisa", s.SendTRISAForm) + send.GET("/trp", s.SendTRPForm) + // The send sunrise message page for authenticated envoy users. + send.GET("/sunrise", s.SendSunriseForm) + } + } // Swagger documentation with Swagger UI hosted from a CDN // NOTE: should documentation require authentication? @@ -119,6 +143,7 @@ func (s *Server) setupRoutes() (err error) { s.router.GET("/v1/docs", s.APIDocs) // Sunrise Routes (can be disabled by the middleware) + // These routes are intended for external users to access a sunrise message sunrise := s.router.Group("/sunrise", s.SunriseEnabled()) { // Logs in a sunrise user to allow the external user to be sunrise authenticated. @@ -129,9 +154,6 @@ func (s *Server) setupRoutes() (err error) { sunrise.POST("/reject", sunriseAuth, s.SunriseMessageReject) sunrise.POST("/accept", sunriseAuth, s.SunriseMessageAccept) sunrise.GET("/download", sunriseAuth, s.SunriseMessageDownload) - - // The send sunrise message page for authenticated envoy users. - sunrise.GET("/message", authenticate, authorize(permiss.TravelRuleManage), s.SendMessageForm) } // API Routes (Including Content Negotiated Partials) diff --git a/pkg/web/scene/scene.go b/pkg/web/scene/scene.go index 45932a99..115d3e7b 100644 --- a/pkg/web/scene/scene.go +++ b/pkg/web/scene/scene.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/trisacrypto/envoy/pkg" "github.com/trisacrypto/envoy/pkg/config" + "github.com/trisacrypto/envoy/pkg/store/models" "github.com/trisacrypto/envoy/pkg/web/api/v1" "github.com/trisacrypto/envoy/pkg/web/auth" ) @@ -191,6 +192,15 @@ func (s Scene) TransactionDetail() *api.Transaction { return nil } +func (s Scene) TransactionCounts() *models.TransactionCounts { + if data, ok := s[APIData]; ok { + if out, ok := data.(*models.TransactionCounts); ok { + return out + } + } + return nil +} + func (s Scene) CounterpartyDetail() *api.Counterparty { if data, ok := s[APIData]; ok { if out, ok := data.(*api.Counterparty); ok { diff --git a/pkg/web/static/js/transactions/index.js b/pkg/web/static/js/transactions/index.js new file mode 100644 index 00000000..10809a6d --- /dev/null +++ b/pkg/web/static/js/transactions/index.js @@ -0,0 +1,25 @@ +/* +Application code for the transaction inbox dashboard page. +*/ + +import { createList, createPageSizeSelect } from '../modules/components.js'; +import { isRequestFor } from '../htmx/helpers.js'; + +/* +Post-event handling after htmx has settled the DOM. +*/ +document.addEventListener("htmx:afterSettle", function(e) { + /* + Whenever the apikey list is refreshed, make sure the pagination and list controls are + re-initialized since the list table is coming from the HTMX request. + */ + if (isRequestFor(e, "/v1/transactions", "get")) { + const cpList = document.getElementById('transactionList'); + const list = createList(cpList); + + // Initialize Page Size Select + const pageSizeSelect = document.getElementById('pageSizeSelect'); + createPageSizeSelect(pageSizeSelect, list); + return; + }; +}); \ No newline at end of file diff --git a/pkg/web/sunrise.go b/pkg/web/sunrise.go index dbd24083..e4802c6d 100644 --- a/pkg/web/sunrise.go +++ b/pkg/web/sunrise.go @@ -28,8 +28,8 @@ import ( const GenericComplianceName = "A VASP Compliance Team using TRISA Envoy" -func (s *Server) SendMessageForm(c *gin.Context) { - c.HTML(http.StatusOK, "send_message.html", scene.New(c)) +func (s *Server) SendSunriseForm(c *gin.Context) { + c.HTML(http.StatusOK, "dashboard/send/sunrise.html", scene.New(c)) } // The incoming request should be coming from a compliance officer at a VASP who has diff --git a/pkg/web/templates/dashboard/send/sunrise.html b/pkg/web/templates/dashboard/send/sunrise.html new file mode 100644 index 00000000..1097235a --- /dev/null +++ b/pkg/web/templates/dashboard/send/sunrise.html @@ -0,0 +1,4 @@ +{{ template "dashboard.html" . }} +{{ define "title" }}Send Sunrise Message | TRISA Envoy{{ end }} +{{ define "pretitle" }}Travel Rule{{ end }} +{{ define "pagetitle" }}Send Sunrise Email{{ end }} \ No newline at end of file diff --git a/pkg/web/templates/dashboard/transactions/list.html b/pkg/web/templates/dashboard/transactions/list.html index 037209bc..ef841e1c 100644 --- a/pkg/web/templates/dashboard/transactions/list.html +++ b/pkg/web/templates/dashboard/transactions/list.html @@ -1,4 +1,67 @@ {{ template "dashboard.html" . }} -{{ define "title" }}Travel Rule Transactions | TRISA Envoy{{ end }} +{{ define "title" }}Travel Rule Transfers | TRISA Envoy{{ end }} {{ define "pretitle" }}Travel Rule{{ end }} -{{ define "pagetitle" }}Transactions & Messages{{ end }} \ No newline at end of file +{{ define "pagetitle" }}Transfers & Messages{{ end }} + +{{- define "modals" }} +{{- end }} + +{{- define "header-actions" }} +{{- if not .IsViewOnly }} +
+ + Send New Transfer + + + +
+ + + +{{- end }} +{{- end }} + + +{{- define "tabs" }} +{{ $counts := .TransactionCounts }} +
+ +
+{{- end }} + +{{- define "main" }} +
+
+
+
+ Loading... +
+
+
+
+{{- end }} + +{{- define "appcode" }} + + +{{- end }} \ No newline at end of file diff --git a/pkg/web/templates/partials/transactions/list.html b/pkg/web/templates/partials/transactions/list.html index e69de29b..57863b6c 100644 --- a/pkg/web/templates/partials/transactions/list.html +++ b/pkg/web/templates/partials/transactions/list.html @@ -0,0 +1,101 @@ +{{- $canEditTransfers := not .IsViewOnly -}} +{{- with .TransactionsList -}} +{{ if .Transactions }} +
+
+
+
+ {{ template "tableSearch" . }} +
+
+ {{ template "tablePageSize" . }} +
+
+ + +
+
+
+
+ + + + + + + + + + + + {{ range .Transactions }} + + + + + + + + + {{ end }} + +
+ Status + + Counterparty + + Originator + + Beneficiary + + Last Updated +
{{ .Status }}{{ .Counterparty }}{{ .Originator }}{{ .Beneficiary }} + + +
+
+ {{ template "tablePagination" . }} +
+{{ else }} +
+
+
+ ... +

No transfers yet!

+

+ Send a compliance travel rule message to get started! +

+
+
+
+{{- end }} +{{- end }} \ No newline at end of file diff --git a/pkg/web/transactions.go b/pkg/web/transactions.go index 90f3928a..7b8e49e5 100644 --- a/pkg/web/transactions.go +++ b/pkg/web/transactions.go @@ -65,7 +65,7 @@ func (s *Server) ListTransactions(c *gin.Context) { c.Negotiate(http.StatusOK, gin.Negotiate{ Offered: []string{binding.MIMEJSON, binding.MIMEHTML}, Data: out, - HTMLName: "transaction_list.html", + HTMLName: "partials/transactions/list.html", HTMLData: scene.New(c).WithAPIData(out), }) } From 69e8fb87e4e0c189c2e92788c01aa0949e726fa7 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Sat, 8 Mar 2025 08:03:29 -0600 Subject: [PATCH 2/6] transaction list complete --- pkg/web/api/v1/transaction.go | 82 -------------- pkg/web/scene/scene.go | 25 ++++- pkg/web/scene/transaction.go | 106 ++++++++++++++++++ pkg/web/static/js/transactions/index.js | 8 ++ .../dashboard/transactions/list.html | 18 ++- .../templates/partials/transactions/list.html | 48 ++++++-- 6 files changed, 192 insertions(+), 95 deletions(-) create mode 100644 pkg/web/scene/transaction.go diff --git a/pkg/web/api/v1/transaction.go b/pkg/web/api/v1/transaction.go index ec676e1a..367bc23a 100644 --- a/pkg/web/api/v1/transaction.go +++ b/pkg/web/api/v1/transaction.go @@ -10,8 +10,6 @@ import ( "github.com/rs/zerolog/log" "github.com/trisacrypto/envoy/pkg/store/models" - "golang.org/x/text/cases" - "golang.org/x/text/language" "github.com/trisacrypto/trisa/pkg/iso3166" "github.com/trisacrypto/trisa/pkg/ivms101" @@ -249,86 +247,6 @@ func (e *Envelope) Dump() string { return string(data) } -//=========================================================================== -// Transaction Status Helpers -//=========================================================================== - -const ( - colorUnspecified = "text-gray-500" - tooltipUnspecified = "The transfer state is unknown or purposefully not specified." - - colorDraft = "text-gray-500" - tooltipDraft = "The TRISA exchange is in a draft state and has not been sent." - - colorPending = "text-yellow-700" - tooltipPending = "Action is required by the sending party, await a following RPC." - - colorReview = "text-blue-700" - tooltipReview = "Action is required by the receiving party." - - colorRepair = "text-warning" - tooltipRepair = "Some part of the payload of the TRISA exchange requires repair." - - colorAccepted = "text-success" - tooltipAccepted = "The TRISA exchange is accepted and the counterparty is awaiting the on-chain transaction." - - colorCompleted = "text-success" - tooltipCompleted = "The TRISA exchange and the on-chain transaction have been completed." - - colorRejected = "text-warning" - tooltipRejected = "The TRISA exchange is rejected and no on-chain transaction should proceed." -) - -func (c *Transaction) TitleStatus() string { - return cases.Title(language.English).String(c.Status) -} - -func (c *Transaction) ColorStatus() string { - switch c.Status { - case models.StatusUnspecified, "": - return colorUnspecified - case models.StatusDraft: - return colorDraft - case models.StatusPending: - return colorPending - case models.StatusReview: - return colorReview - case models.StatusRepair: - return colorRepair - case models.StatusAccepted: - return colorAccepted - case models.StatusCompleted: - return colorCompleted - case models.StatusRejected: - return colorRejected - default: - panic(fmt.Errorf("unhandled color for status %q", c.Status)) - } -} - -func (c *Transaction) TooltipStatus() string { - switch c.Status { - case models.StatusUnspecified, "": - return tooltipUnspecified - case models.StatusDraft: - return tooltipDraft - case models.StatusPending: - return tooltipPending - case models.StatusReview: - return tooltipReview - case models.StatusRepair: - return tooltipRepair - case models.StatusAccepted: - return tooltipAccepted - case models.StatusCompleted: - return tooltipCompleted - case models.StatusRejected: - return tooltipRejected - default: - panic(fmt.Errorf("unhandled tooltip for status %q", c.Status)) - } -} - //=========================================================================== // SecureEnvelopes //=========================================================================== diff --git a/pkg/web/scene/scene.go b/pkg/web/scene/scene.go index 115d3e7b..ef5aa1e4 100644 --- a/pkg/web/scene/scene.go +++ b/pkg/web/scene/scene.go @@ -174,19 +174,34 @@ func (s Scene) CounterpartyList() *api.CounterpartyList { return nil } -func (s Scene) TransactionsList() *api.TransactionsList { +func (s Scene) TransactionsList() *TransactionList { if data, ok := s[APIData]; ok { - if out, ok := data.(*api.TransactionsList); ok { + if txns, ok := data.(*api.TransactionsList); ok { + out := &TransactionList{ + Page: txns.Page, + Transactions: make([]*Transaction, len(txns.Transactions)), + } + + for i, txn := range txns.Transactions { + out.Transactions[i] = &Transaction{ + Transaction: *txn, + Status: Status(txn.Status), + } + } + return out } } return nil } -func (s Scene) TransactionDetail() *api.Transaction { +func (s Scene) TransactionDetail() *Transaction { if data, ok := s[APIData]; ok { - if out, ok := data.(*api.Transaction); ok { - return out + if tx, ok := data.(*api.Transaction); ok { + return &Transaction{ + Transaction: *tx, + Status: Status(tx.Status), + } } } return nil diff --git a/pkg/web/scene/transaction.go b/pkg/web/scene/transaction.go new file mode 100644 index 00000000..79582d9b --- /dev/null +++ b/pkg/web/scene/transaction.go @@ -0,0 +1,106 @@ +package scene + +import ( + "fmt" + + "github.com/trisacrypto/envoy/pkg/store/models" + "github.com/trisacrypto/envoy/pkg/web/api/v1" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// Converted from an *api.TransactionList to provide additional UI-specific functionality. +type TransactionList struct { + Page *api.PageQuery + Transactions []*Transaction +} + +// Wraps an *api.Transaction to provide additional UI-specific functionality. +type Transaction struct { + api.Transaction + Status Status +} + +//=========================================================================== +// Status Helpers +//=========================================================================== + +// Status wraps a Transaction status to provide additional information such as class, +// tooltip, color, icons, etc for the UI. +type Status string + +const ( + colorUnspecified = "secondary" + tooltipUnspecified = "The transfer state is unknown or purposefully not specified." + + colorDraft = "light" + tooltipDraft = "The TRISA exchange is in a draft state and has not been sent." + + colorPending = "info" + tooltipPending = "Action is required by the sending party, await a following RPC." + + colorReview = "primary" + tooltipReview = "Action is required by the receiving party." + + colorRepair = "warning" + tooltipRepair = "Some part of the payload of the TRISA exchange requires repair." + + colorAccepted = "success" + tooltipAccepted = "The TRISA exchange is accepted and the counterparty is awaiting the on-chain transaction." + + colorCompleted = "success" + tooltipCompleted = "The TRISA exchange and the on-chain transaction have been completed." + + colorRejected = "danger" + tooltipRejected = "The TRISA exchange is rejected and no on-chain transaction should proceed." +) + +func (s Status) String() string { + return cases.Title(language.English).String(string(s)) +} + +func (s Status) Color() string { + switch s { + case models.StatusUnspecified, "": + return colorUnspecified + case models.StatusDraft: + return colorDraft + case models.StatusPending: + return colorPending + case models.StatusReview: + return colorReview + case models.StatusRepair: + return colorRepair + case models.StatusAccepted: + return colorAccepted + case models.StatusCompleted: + return colorCompleted + case models.StatusRejected: + return colorRejected + default: + panic(fmt.Errorf("unhandled color for status %q", s)) + } +} + +func (s Status) Tooltip() string { + switch s { + case models.StatusUnspecified, "": + return tooltipUnspecified + case models.StatusDraft: + return tooltipDraft + case models.StatusPending: + return tooltipPending + case models.StatusReview: + return tooltipReview + case models.StatusRepair: + return tooltipRepair + case models.StatusAccepted: + return tooltipAccepted + case models.StatusCompleted: + return tooltipCompleted + case models.StatusRejected: + return tooltipRejected + default: + panic(fmt.Errorf("unhandled tooltip for status %q", s)) + } +} diff --git a/pkg/web/static/js/transactions/index.js b/pkg/web/static/js/transactions/index.js index 10809a6d..bfce8ea9 100644 --- a/pkg/web/static/js/transactions/index.js +++ b/pkg/web/static/js/transactions/index.js @@ -4,6 +4,7 @@ Application code for the transaction inbox dashboard page. import { createList, createPageSizeSelect } from '../modules/components.js'; import { isRequestFor } from '../htmx/helpers.js'; +// import { Tooltip } from 'bootstrap'; /* Post-event handling after htmx has settled the DOM. @@ -20,6 +21,13 @@ document.addEventListener("htmx:afterSettle", function(e) { // Initialize Page Size Select const pageSizeSelect = document.getElementById('pageSizeSelect'); createPageSizeSelect(pageSizeSelect, list); + + // Initialize the status tooltips + const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + tooltips.forEach(tooltip => { + new Tooltip(tooltip); + }); + return; }; }); \ No newline at end of file diff --git a/pkg/web/templates/dashboard/transactions/list.html b/pkg/web/templates/dashboard/transactions/list.html index ef841e1c..0b2cadd1 100644 --- a/pkg/web/templates/dashboard/transactions/list.html +++ b/pkg/web/templates/dashboard/transactions/list.html @@ -3,6 +3,22 @@ {{ define "pretitle" }}Travel Rule{{ end }} {{ define "pagetitle" }}Transfers & Messages{{ end }} +{{ define "appstyle" }} + +{{ end }} + {{- define "modals" }} {{- end }} @@ -23,7 +39,7 @@ - + {{- end }} {{- end }} diff --git a/pkg/web/templates/partials/transactions/list.html b/pkg/web/templates/partials/transactions/list.html index 57863b6c..bc7d636e 100644 --- a/pkg/web/templates/partials/transactions/list.html +++ b/pkg/web/templates/partials/transactions/list.html @@ -1,7 +1,7 @@ {{- $canEditTransfers := not .IsViewOnly -}} {{- with .TransactionsList -}} {{ if .Transactions }} -
+
@@ -35,6 +35,9 @@

Filters

+ @@ -47,6 +50,12 @@

Filters

+ + @@ -55,10 +64,35 @@

Filters

{{ range .Transactions }} - + + - - + + + +
+ Source + Status Beneficiary + Virtual Asset + + Amount + Last Updated
{{ .Status }} + {{- if eq .Source "remote" -}} + + {{- else if eq .Source "local" -}} + + {{- else -}} + + {{- end -}} + {{ .Status }} {{ .Counterparty }}{{ .Originator }}{{ .Beneficiary }} + {{- if .Originator -}} + {{- .Originator -}} + {{- .OriginatorAddress -}} + {{- else -}} + {{- .OriginatorAddress -}} + {{- end -}} + + {{- if .Beneficiary -}} + {{- .Beneficiary -}} + {{- .BeneficiaryAddress -}} + {{- else -}} + {{- .BeneficiaryAddress -}} + {{- end -}} + {{ .VirtualAsset }}{{ .Amount }} @@ -67,12 +101,12 @@

Filters

From a71bb725275a1c97aa0f01255609f0707464ad4f Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Sat, 8 Mar 2025 10:31:17 -0600 Subject: [PATCH 3/6] archive queries --- pkg/store/mock/store.go | 2 +- pkg/store/models/pagination.go | 4 +- pkg/store/models/transaction.go | 7 ++ pkg/store/sqlite/transactions.go | 13 ++- pkg/store/store.go | 2 +- pkg/web/api/v1/transaction.go | 86 ++++++++++++++----- pkg/web/api/v1/user.go | 13 ++- pkg/web/export.go | 4 +- pkg/web/pages.go | 4 +- pkg/web/scene/transaction.go | 2 +- pkg/web/static/js/htmx/helpers.js | 18 +++- pkg/web/static/js/transactions/index.js | 10 ++- .../dashboard/transactions/list.html | 6 +- pkg/web/templates/docs/openapi/openapi.json | 44 ++++++++++ .../templates/partials/transactions/list.html | 12 ++- pkg/web/transactions.go | 19 ++-- 16 files changed, 189 insertions(+), 57 deletions(-) diff --git a/pkg/store/mock/store.go b/pkg/store/mock/store.go index 4cba28c1..d7338609 100644 --- a/pkg/store/mock/store.go +++ b/pkg/store/mock/store.go @@ -23,7 +23,7 @@ func (s *Store) Close() error { return nil } -func (s *Store) ListTransactions(context.Context, *models.PageInfo) (*models.TransactionPage, error) { +func (s *Store) ListTransactions(context.Context, *models.TransactionPageInfo) (*models.TransactionPage, error) { return nil, nil } diff --git a/pkg/store/models/pagination.go b/pkg/store/models/pagination.go index d7916850..51c079e4 100644 --- a/pkg/store/models/pagination.go +++ b/pkg/store/models/pagination.go @@ -11,8 +11,8 @@ type PageInfo struct { } type TransactionPage struct { - Transactions []*Transaction `json:"transactions"` - Page *PageInfo `json:"page"` + Transactions []*Transaction `json:"transactions"` + Page *TransactionPageInfo `json:"page"` } type SecureEnvelopePage struct { diff --git a/pkg/store/models/transaction.go b/pkg/store/models/transaction.go index fa5764f6..d98f4d5b 100644 --- a/pkg/store/models/transaction.go +++ b/pkg/store/models/transaction.go @@ -70,6 +70,13 @@ type TransactionCounts struct { Archived map[string]int // Archived transaction counts by status } +type TransactionPageInfo struct { + PageInfo + Status []string `json:"status,omitempty"` + VirtualAsset []string `json:"asset,omitempty"` + Archives bool `json:"archives,omitempty"` +} + type SecureEnvelope struct { Model EnvelopeID uuid.UUID // Also a foreign key reference to the Transaction diff --git a/pkg/store/sqlite/transactions.go b/pkg/store/sqlite/transactions.go index ccdc1e94..d849b104 100644 --- a/pkg/store/sqlite/transactions.go +++ b/pkg/store/sqlite/transactions.go @@ -18,9 +18,9 @@ import ( // Transaction CRUD interface //========================================================================== -const listTransactionsSQL = "SELECT t.id, t.source, t.status, t.counterparty, t.counterparty_id, t.originator, t.originator_address, t.beneficiary, t.beneficiary_address, t.virtual_asset, t.amount, t.archived, t.archived_on, t.last_update, t.modified, t.created, count(e.id) AS numEnvelopes FROM transactions t LEFT JOIN secure_envelopes e ON t.id=e.envelope_id WHERE t.archived=0 GROUP BY t.id ORDER BY t.created DESC" +const listTransactionsSQL = "SELECT t.id, t.source, t.status, t.counterparty, t.counterparty_id, t.originator, t.originator_address, t.beneficiary, t.beneficiary_address, t.virtual_asset, t.amount, t.archived, t.archived_on, t.last_update, t.modified, t.created, count(e.id) AS numEnvelopes FROM transactions t LEFT JOIN secure_envelopes e ON t.id=e.envelope_id WHERE t.archived=:archives GROUP BY t.id ORDER BY t.created DESC" -func (s *Store) ListTransactions(ctx context.Context, page *models.PageInfo) (out *models.TransactionPage, err error) { +func (s *Store) ListTransactions(ctx context.Context, page *models.TransactionPageInfo) (out *models.TransactionPage, err error) { var tx *sql.Tx if tx, err = s.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { return nil, err @@ -30,11 +30,16 @@ func (s *Store) ListTransactions(ctx context.Context, page *models.PageInfo) (ou // TODO: handle pagination out = &models.TransactionPage{ Transactions: make([]*models.Transaction, 0), - Page: models.PageInfoFrom(page), + Page: &models.TransactionPageInfo{ + PageInfo: *models.PageInfoFrom(&page.PageInfo), + Status: page.Status, + VirtualAsset: page.VirtualAsset, + Archives: page.Archives, + }, } var rows *sql.Rows - if rows, err = tx.Query(listTransactionsSQL); err != nil { + if rows, err = tx.Query(listTransactionsSQL, sql.Named("archives", page.Archives)); err != nil { // TODO: handle database specific errors return nil, err } diff --git a/pkg/store/store.go b/pkg/store/store.go index 93968a4a..465838eb 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -79,7 +79,7 @@ type Stats interface { // part of completing a travel rule exchange for the transaction. type TransactionStore interface { SecureEnvelopeStore - ListTransactions(context.Context, *models.PageInfo) (*models.TransactionPage, error) + ListTransactions(context.Context, *models.TransactionPageInfo) (*models.TransactionPage, error) CreateTransaction(context.Context, *models.Transaction) error RetrieveTransaction(context.Context, uuid.UUID) (*models.Transaction, error) UpdateTransaction(context.Context, *models.Transaction) error diff --git a/pkg/web/api/v1/transaction.go b/pkg/web/api/v1/transaction.go index 367bc23a..87f4427a 100644 --- a/pkg/web/api/v1/transaction.go +++ b/pkg/web/api/v1/transaction.go @@ -53,6 +53,10 @@ type Transaction struct { Modified time.Time `json:"modified"` } +type TransactionQuery struct { + Detail string `json:"detail" url:"detail,omitempty" form:"detail"` +} + type SecureEnvelope struct { ID ulid.ULID `json:"id"` EnvelopeID uuid.UUID `json:"envelope_id"` @@ -106,24 +110,22 @@ type Repair struct { Envelope *Envelope } -type TransactionQuery struct { - Detail string `json:"detail" url:"detail,omitempty" form:"detail"` -} - type EnvelopeQuery struct { Decrypt bool `json:"decrypt" url:"decrypt,omitempty" form:"decrypt"` Archives bool `json:"archives" url:"archives,omitempty" form:"archives"` Direction string `json:"direction,omitempty" url:"direction,omitempty" form:"direction"` } -type EnvelopeListQuery struct { - PageQuery - EnvelopeQuery +type TransactionsList struct { + Page *TransactionListQuery `json:"page"` + Transactions []*Transaction `json:"transactions"` } -type TransactionsList struct { - Page *PageQuery `json:"page"` - Transactions []*Transaction `json:"transactions"` +type TransactionListQuery struct { + PageQuery + Status []string `json:"status,omitempty" url:"status,omitempty" form:"status"` + VirtualAsset []string `json:"asset,omitempty" url:"asset,omitempty" form:"asset"` + Archives bool `json:"archives,omitempty" url:"archives,omitempty" form:"archives"` } type EnvelopesList struct { @@ -133,6 +135,11 @@ type EnvelopesList struct { DecryptedEnvelopes []*Envelope `json:"decrypted_envelopes,omitempty"` } +type EnvelopeListQuery struct { + PageQuery + EnvelopeQuery +} + //=========================================================================== // Transactions //=========================================================================== @@ -170,7 +177,14 @@ func NewTransaction(model *models.Transaction) (*Transaction, error) { func NewTransactionList(page *models.TransactionPage) (out *TransactionsList, err error) { out = &TransactionsList{ - Page: &PageQuery{}, + Page: &TransactionListQuery{ + PageQuery: PageQuery{ + PageSize: int(page.Page.PageSize), + }, + Status: page.Page.Status, + VirtualAsset: page.Page.VirtualAsset, + Archives: page.Page.Archives, + }, Transactions: make([]*Transaction, 0, len(page.Transactions)), } @@ -238,15 +252,6 @@ func (c *Transaction) Model() (model *models.Transaction, err error) { return model, nil } -func (e *Envelope) Dump() string { - data, err := json.Marshal(e) - if err != nil { - log.Warn().Err(err).Msg("could not marshal envelope data") - return "" - } - return string(data) -} - //=========================================================================== // SecureEnvelopes //=========================================================================== @@ -421,6 +426,15 @@ func NewEnvelopeList(page *models.SecureEnvelopePage, envelopes []*envelope.Enve return out, nil } +func (e *Envelope) Dump() string { + data, err := json.Marshal(e) + if err != nil { + log.Warn().Err(err).Msg("could not marshal envelope data") + return "" + } + return string(data) +} + func (e *Envelope) Validate() (err error) { // Perform lightweight validation of the payload if e.Error != nil { @@ -604,6 +618,38 @@ func (q *TransactionQuery) Validate() (err error) { return err } +func (q *TransactionListQuery) Validate() (err error) { + if len(q.Status) > 0 { + for i, status := range q.Status { + q.Status[i] = strings.ToLower(strings.TrimSpace(status)) + if !models.ValidStatus(q.Status[i]) { + err = ValidationError(err, IncorrectField("status", "invalid status enum")) + break + } + } + } + + if len(q.VirtualAsset) > 0 { + for i, asset := range q.VirtualAsset { + q.VirtualAsset[i] = strings.ToUpper(strings.TrimSpace(asset)) + } + } + + return err +} + +func (q *TransactionListQuery) Query() (query *models.TransactionPageInfo) { + query = &models.TransactionPageInfo{ + PageInfo: models.PageInfo{ + PageSize: uint32(q.PageSize), + }, + Status: q.Status, + VirtualAsset: q.VirtualAsset, + Archives: q.Archives, + } + return query +} + //=========================================================================== // Envelope Query //=========================================================================== diff --git a/pkg/web/api/v1/user.go b/pkg/web/api/v1/user.go index 60690480..f4c2d599 100644 --- a/pkg/web/api/v1/user.go +++ b/pkg/web/api/v1/user.go @@ -28,8 +28,8 @@ type User struct { } type UserList struct { - Page *PageQuery `json:"page"` - Users []*User `json:"users"` + Page *UserListQuery `json:"page"` + Users []*User `json:"users"` } type UserPassword struct { @@ -39,7 +39,7 @@ type UserPassword struct { type UserListQuery struct { PageQuery - Role string `json:"role" url:"role,omitempty" form:"role"` + Role string `json:"role,omitempty" url:"role,omitempty" form:"role"` } type UserQuery struct { @@ -68,7 +68,12 @@ func NewUser(model *models.User) (out *User, err error) { func NewUserList(page *models.UserPage) (out *UserList, err error) { out = &UserList{ - Page: &PageQuery{}, + Page: &UserListQuery{ + PageQuery: PageQuery{ + PageSize: int(page.Page.PageSize), + }, + Role: page.Page.Role, + }, Users: make([]*User, 0, len(page.Users)), } diff --git a/pkg/web/export.go b/pkg/web/export.go index 00f074a9..450e3d5b 100644 --- a/pkg/web/export.go +++ b/pkg/web/export.go @@ -29,7 +29,7 @@ func (s *Server) ExportTransactions(c *gin.Context) { var ( err error page *models.TransactionPage - info *models.PageInfo + info *models.TransactionPageInfo ) // Create the filename for export based on the current date @@ -51,7 +51,7 @@ func (s *Server) ExportTransactions(c *gin.Context) { } // Fetch 50 records per page from the database - info = &models.PageInfo{PageSize: 50, NextPageID: ulid.Null} + info = &models.TransactionPageInfo{PageInfo: models.PageInfo{PageSize: 50, NextPageID: ulid.Null}} // TODO: we'll probably want to load more information from the secure envelope // besides what's in the transaction and if we do that, we'll want a transaction diff --git a/pkg/web/pages.go b/pkg/web/pages.go index 01c27afe..4b80f84a 100644 --- a/pkg/web/pages.go +++ b/pkg/web/pages.go @@ -5,7 +5,6 @@ import ( "net/http" "strings" - "github.com/rs/zerolog/log" "github.com/trisacrypto/envoy/pkg" "github.com/trisacrypto/envoy/pkg/web/api/v1" "github.com/trisacrypto/envoy/pkg/web/htmx" @@ -54,10 +53,9 @@ func (s *Server) ResetPasswordSuccessPage(c *gin.Context) { func (s *Server) TransactionsListPage(c *gin.Context) { // Count the number of transactions in the database (ignore errors) counts, _ := s.store.CountTransactions(c.Request.Context()) - log.Warn().Str("counts", fmt.Sprintf("%+v", counts)).Msg("counts") ctx := scene.New(c).WithAPIData(counts) - ctx["Status"] = strings.ToLower(c.Query("status")) + ctx["Archives"] = strings.ToLower(c.Query("archives")) c.HTML(http.StatusOK, "dashboard/transactions/list.html", ctx) } diff --git a/pkg/web/scene/transaction.go b/pkg/web/scene/transaction.go index 79582d9b..8f9635a7 100644 --- a/pkg/web/scene/transaction.go +++ b/pkg/web/scene/transaction.go @@ -11,7 +11,7 @@ import ( // Converted from an *api.TransactionList to provide additional UI-specific functionality. type TransactionList struct { - Page *api.PageQuery + Page *api.TransactionListQuery Transactions []*Transaction } diff --git a/pkg/web/static/js/htmx/helpers.js b/pkg/web/static/js/htmx/helpers.js index c931652c..f361f24a 100644 --- a/pkg/web/static/js/htmx/helpers.js +++ b/pkg/web/static/js/htmx/helpers.js @@ -7,12 +7,12 @@ export function isRequestFor(e, path, method) { // Check the request config for the path and method if it has been configured. const config = e.detail?.requestConfig; if (config) { - return config.path === path && config.verb === method; + return urlPath(config.path) === path && config.verb === method; } // Check the detail directly if this is during a request config event. if (e.detail?.path && e.detail?.verb) { - return e.detail.path === path && e.detail.verb === method; + return urlPath(e.detail.path) === path && e.detail.verb === method; } // Otherwise return false since we can't determine the configuration. @@ -35,11 +35,11 @@ export function isRequestMatch(e, pattern, method) { const config = e.detail?.requestConfig; if (config) { - return config.verb === method && pattern.test(config.path); + return config.verb === method && pattern.test(urlPath(config.path)); } if (e.detail?.verb) { - return e.detail.verb === method && pattern.test(e.detail.path); + return e.detail.verb === method && pattern.test(urlPath(e.detail.path)); } return false; @@ -48,4 +48,14 @@ export function isRequestMatch(e, pattern, method) { // Check the status of an HTMX request. export function checkStatus(e, status) { return e.detail?.xhr?.status === status; +} + +// Removes any query strings from the path. +export function urlPath(uri) { + try { + const url = new URL(uri); + return url.pathname; + } catch (InvalidURL) { + return uri.split('?')[0]; + } } \ No newline at end of file diff --git a/pkg/web/static/js/transactions/index.js b/pkg/web/static/js/transactions/index.js index bfce8ea9..4a1a42be 100644 --- a/pkg/web/static/js/transactions/index.js +++ b/pkg/web/static/js/transactions/index.js @@ -16,11 +16,13 @@ document.addEventListener("htmx:afterSettle", function(e) { */ if (isRequestFor(e, "/v1/transactions", "get")) { const cpList = document.getElementById('transactionList'); - const list = createList(cpList); + if (cpList) { + const list = createList(cpList); - // Initialize Page Size Select - const pageSizeSelect = document.getElementById('pageSizeSelect'); - createPageSizeSelect(pageSizeSelect, list); + // Initialize Page Size Select + const pageSizeSelect = document.getElementById('pageSizeSelect'); + createPageSizeSelect(pageSizeSelect, list); + } // Initialize the status tooltips const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); diff --git a/pkg/web/templates/dashboard/transactions/list.html b/pkg/web/templates/dashboard/transactions/list.html index 0b2cadd1..b93fdb1c 100644 --- a/pkg/web/templates/dashboard/transactions/list.html +++ b/pkg/web/templates/dashboard/transactions/list.html @@ -51,12 +51,12 @@