feat: aggregate account and transaction activity logs on space activity page
This commit is contained in:
parent
c96595d41e
commit
9826068208
7 changed files with 135 additions and 38 deletions
|
|
@ -676,9 +676,9 @@ func (h *spaceHandler) SpaceActivityPage(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := h.auditLogService.Count(spaceID)
|
total, err := h.accountActivitySvc.CountSpace(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to count audit logs", "error", err, "space_id", spaceID)
|
slog.Error("failed to count space activity", "error", err, "space_id", spaceID)
|
||||||
ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError)
|
ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -691,9 +691,9 @@ func (h *spaceHandler) SpaceActivityPage(w http.ResponseWriter, r *http.Request)
|
||||||
page = totalPages
|
page = totalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
logs, err := h.auditLogService.List(spaceID, perPage, (page-1)*perPage)
|
rows, err := h.accountActivitySvc.ListSpace(spaceID, perPage, (page-1)*perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to list audit logs", "error", err, "space_id", spaceID)
|
slog.Error("failed to list space activity", "error", err, "space_id", spaceID)
|
||||||
ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError)
|
ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -701,7 +701,7 @@ func (h *spaceHandler) SpaceActivityPage(w http.ResponseWriter, r *http.Request)
|
||||||
ui.Render(w, r, pages.SpaceActivityPage(pages.SpaceActivityPageProps{
|
ui.Render(w, r, pages.SpaceActivityPage(pages.SpaceActivityPageProps{
|
||||||
SpaceID: space.ID,
|
SpaceID: space.ID,
|
||||||
SpaceName: space.Name,
|
SpaceName: space.Name,
|
||||||
Logs: logs,
|
Rows: rows,
|
||||||
CurrentPage: page,
|
CurrentPage: page,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
TotalCount: total,
|
TotalCount: total,
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ package model
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// AccountActivityRow is a unified row representing either an account-scoped space
|
// ActivityRow is a unified row representing either a space-scoped audit entry or a
|
||||||
// audit entry or a transaction audit entry that belongs to the account. Exactly one
|
// transaction audit entry. Exactly one of SpaceLog / TxLog is set. Used by both the
|
||||||
// of SpaceLog / TxLog is set.
|
// account-level and space-level activity feeds.
|
||||||
type AccountActivityRow struct {
|
type ActivityRow struct {
|
||||||
SpaceLog *SpaceAuditLogWithActor
|
SpaceLog *SpaceAuditLogWithActor
|
||||||
TxLog *TransactionAuditLogWithActor
|
TxLog *TransactionAuditLogWithActor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r AccountActivityRow) Timestamp() time.Time {
|
func (r ActivityRow) Timestamp() time.Time {
|
||||||
if r.SpaceLog != nil {
|
if r.SpaceLog != nil {
|
||||||
return r.SpaceLog.CreatedAt
|
return r.SpaceLog.CreatedAt
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ type TransactionAuditLogRepository interface {
|
||||||
CountByTransaction(transactionID string) (int, error)
|
CountByTransaction(transactionID string) (int, error)
|
||||||
ListByAccount(accountID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error)
|
ListByAccount(accountID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error)
|
||||||
CountByAccount(accountID string) (int, error)
|
CountByAccount(accountID string) (int, error)
|
||||||
|
ListBySpace(spaceID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error)
|
||||||
|
CountBySpace(spaceID string) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type transactionAuditLogRepository struct {
|
type transactionAuditLogRepository struct {
|
||||||
|
|
@ -88,3 +90,38 @@ func (r *transactionAuditLogRepository) CountByAccount(accountID string) (int, e
|
||||||
accountID)
|
accountID)
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBySpace returns transaction audit entries for any transaction belonging to an
|
||||||
|
// account in this space. Resolves the account via the live transactions row, falling
|
||||||
|
// back to the metadata account_id (set on creation) so entries for deleted transactions
|
||||||
|
// still surface as long as the account itself exists.
|
||||||
|
func (r *transactionAuditLogRepository) ListBySpace(spaceID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
a.id, a.transaction_id, a.actor_id, a.action, a.metadata, a.created_at,
|
||||||
|
actor.name AS actor_name, actor.email AS actor_email
|
||||||
|
FROM transaction_audit_logs a
|
||||||
|
LEFT JOIN users actor ON actor.id = a.actor_id
|
||||||
|
LEFT JOIN transactions t ON t.id = a.transaction_id
|
||||||
|
LEFT JOIN accounts acc
|
||||||
|
ON acc.id = COALESCE(t.account_id, a.metadata->>'account_id')
|
||||||
|
WHERE acc.space_id = $1
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3;`
|
||||||
|
var logs []*model.TransactionAuditLogWithActor
|
||||||
|
err := r.db.Select(&logs, query, spaceID, limit, offset)
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *transactionAuditLogRepository) CountBySpace(spaceID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.db.Get(&count,
|
||||||
|
`SELECT COUNT(*)
|
||||||
|
FROM transaction_audit_logs a
|
||||||
|
LEFT JOIN transactions t ON t.id = a.transaction_id
|
||||||
|
LEFT JOIN accounts acc
|
||||||
|
ON acc.id = COALESCE(t.account_id, a.metadata->>'account_id')
|
||||||
|
WHERE acc.space_id = $1;`,
|
||||||
|
spaceID)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ func NewAccountActivityService(spaceAudit *SpaceAuditLogService, txAudit *Transa
|
||||||
// Rather than a SQL UNION across two heterogeneous tables, we fetch up to (offset+limit)
|
// Rather than a SQL UNION across two heterogeneous tables, we fetch up to (offset+limit)
|
||||||
// from each side and merge in Go. Audit volume per account is low, so the simplicity
|
// from each side and merge in Go. Audit volume per account is low, so the simplicity
|
||||||
// outweighs the slight overfetch.
|
// outweighs the slight overfetch.
|
||||||
func (s *AccountActivityService) List(accountID string, limit, offset int) ([]model.AccountActivityRow, error) {
|
func (s *AccountActivityService) List(accountID string, limit, offset int) ([]model.ActivityRow, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 25
|
limit = 25
|
||||||
}
|
}
|
||||||
|
|
@ -43,12 +43,12 @@ func (s *AccountActivityService) List(accountID string, limit, offset int) ([]mo
|
||||||
return nil, fmt.Errorf("failed to list transaction events: %w", err)
|
return nil, fmt.Errorf("failed to list transaction events: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := make([]model.AccountActivityRow, 0, len(spaceLogs)+len(txLogs))
|
rows := make([]model.ActivityRow, 0, len(spaceLogs)+len(txLogs))
|
||||||
for _, l := range spaceLogs {
|
for _, l := range spaceLogs {
|
||||||
rows = append(rows, model.AccountActivityRow{SpaceLog: l})
|
rows = append(rows, model.ActivityRow{SpaceLog: l})
|
||||||
}
|
}
|
||||||
for _, l := range txLogs {
|
for _, l := range txLogs {
|
||||||
rows = append(rows, model.AccountActivityRow{TxLog: l})
|
rows = append(rows, model.ActivityRow{TxLog: l})
|
||||||
}
|
}
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
return rows[i].Timestamp().After(rows[j].Timestamp())
|
return rows[i].Timestamp().After(rows[j].Timestamp())
|
||||||
|
|
@ -75,3 +75,57 @@ func (s *AccountActivityService) Count(accountID string) (int, error) {
|
||||||
}
|
}
|
||||||
return spaceCount + txCount, nil
|
return spaceCount + txCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListSpace returns a merged feed of every audit entry scoped to the space — its own
|
||||||
|
// space_audit_logs (rename, members, account events) plus every transaction event for
|
||||||
|
// transactions whose account belongs to this space. Same in-memory merge as List.
|
||||||
|
func (s *AccountActivityService) ListSpace(spaceID string, limit, offset int) ([]model.ActivityRow, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 25
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
fetchN := offset + limit
|
||||||
|
|
||||||
|
spaceLogs, err := s.spaceAudit.repo.ListBySpace(spaceID, fetchN, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list space events: %w", err)
|
||||||
|
}
|
||||||
|
txLogs, err := s.txAudit.repo.ListBySpace(spaceID, fetchN, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list transaction events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]model.ActivityRow, 0, len(spaceLogs)+len(txLogs))
|
||||||
|
for _, l := range spaceLogs {
|
||||||
|
rows = append(rows, model.ActivityRow{SpaceLog: l})
|
||||||
|
}
|
||||||
|
for _, l := range txLogs {
|
||||||
|
rows = append(rows, model.ActivityRow{TxLog: l})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
return rows[i].Timestamp().After(rows[j].Timestamp())
|
||||||
|
})
|
||||||
|
|
||||||
|
if offset >= len(rows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(rows) {
|
||||||
|
end = len(rows)
|
||||||
|
}
|
||||||
|
return rows[offset:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountActivityService) CountSpace(spaceID string) (int, error) {
|
||||||
|
spaceCount, err := s.spaceAudit.repo.CountBySpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count space events: %w", err)
|
||||||
|
}
|
||||||
|
txCount, err := s.txAudit.repo.CountBySpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count transaction events: %w", err)
|
||||||
|
}
|
||||||
|
return spaceCount + txCount, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,11 @@ func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transacti
|
||||||
TransactionID: input.TransactionID,
|
TransactionID: input.TransactionID,
|
||||||
ActorID: input.ActorID,
|
ActorID: input.ActorID,
|
||||||
Action: model.TransactionAuditActionEdited,
|
Action: model.TransactionAuditActionEdited,
|
||||||
Metadata: map[string]any{"changes": changes},
|
Metadata: map[string]any{
|
||||||
|
"account_id": existing.AccountID,
|
||||||
|
"transaction_type": string(existing.Type),
|
||||||
|
"changes": changes,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return existing, nil
|
return existing, nil
|
||||||
|
|
@ -314,7 +318,11 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra
|
||||||
TransactionID: input.TransactionID,
|
TransactionID: input.TransactionID,
|
||||||
ActorID: input.ActorID,
|
ActorID: input.ActorID,
|
||||||
Action: model.TransactionAuditActionEdited,
|
Action: model.TransactionAuditActionEdited,
|
||||||
Metadata: map[string]any{"changes": changes},
|
Metadata: map[string]any{
|
||||||
|
"account_id": existing.AccountID,
|
||||||
|
"transaction_type": string(existing.Type),
|
||||||
|
"changes": changes,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return existing, nil
|
return existing, nil
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ type SpaceAccountActivityPageProps struct {
|
||||||
SpaceName string
|
SpaceName string
|
||||||
AccountID string
|
AccountID string
|
||||||
AccountName string
|
AccountName string
|
||||||
Rows []model.AccountActivityRow
|
Rows []model.ActivityRow
|
||||||
CurrentPage int
|
CurrentPage int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
TotalCount int
|
TotalCount int
|
||||||
|
|
@ -72,7 +72,7 @@ templ SpaceAccountActivityPage(props SpaceAccountActivityPageProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ accountActivityRow(spaceID, accountID string, row model.AccountActivityRow) {
|
templ accountActivityRow(spaceID, accountID string, row model.ActivityRow) {
|
||||||
if row.SpaceLog != nil {
|
if row.SpaceLog != nil {
|
||||||
<li class="flex gap-3 px-6 py-4">
|
<li class="flex gap-3 px-6 py-4">
|
||||||
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
|
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
type SpaceActivityPageProps struct {
|
type SpaceActivityPageProps struct {
|
||||||
SpaceID string
|
SpaceID string
|
||||||
SpaceName string
|
SpaceName string
|
||||||
Logs []*model.SpaceAuditLogWithActor
|
Rows []model.ActivityRow
|
||||||
CurrentPage int
|
CurrentPage int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
TotalCount int
|
TotalCount int
|
||||||
|
|
@ -37,14 +37,14 @@ templ SpaceActivityPage(props SpaceActivityPageProps) {
|
||||||
</div>
|
</div>
|
||||||
@card.Card(card.Props{Class: "rounded-sm"}) {
|
@card.Card(card.Props{Class: "rounded-sm"}) {
|
||||||
@card.Content(card.ContentProps{Class: "p-0"}) {
|
@card.Content(card.ContentProps{Class: "p-0"}) {
|
||||||
if len(props.Logs) == 0 {
|
if len(props.Rows) == 0 {
|
||||||
<p class="px-6 py-10 text-sm text-muted-foreground text-center">
|
<p class="px-6 py-10 text-sm text-muted-foreground text-center">
|
||||||
No activity yet.
|
No activity yet.
|
||||||
</p>
|
</p>
|
||||||
} else {
|
} else {
|
||||||
<ol class="divide-y">
|
<ol class="divide-y">
|
||||||
for _, log := range props.Logs {
|
for _, row := range props.Rows {
|
||||||
@activityRow(log)
|
@accountActivityRow(props.SpaceID, txAccountIDFromRow(row), row)
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
}
|
}
|
||||||
|
|
@ -57,22 +57,6 @@ templ SpaceActivityPage(props SpaceActivityPageProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ activityRow(log *model.SpaceAuditLogWithActor) {
|
|
||||||
<li class="flex gap-3 px-6 py-4">
|
|
||||||
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
|
|
||||||
@activityIcon(log.Action)
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm">
|
|
||||||
@templ.Raw(activityMessage(log))
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
|
||||||
{ log.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") }
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ activityIcon(action model.SpaceAuditAction) {
|
templ activityIcon(action model.SpaceAuditAction) {
|
||||||
switch action {
|
switch action {
|
||||||
case model.SpaceAuditActionRenamed:
|
case model.SpaceAuditActionRenamed:
|
||||||
|
|
@ -187,6 +171,20 @@ func activityMessage(log *model.SpaceAuditLogWithActor) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// txAccountIDFromRow extracts the account_id from a transaction audit row's metadata.
|
||||||
|
// All transaction audit entries (created/edited/deleted) embed account_id, so this
|
||||||
|
// gives the templ a stable handle for building links from the space-level feed.
|
||||||
|
func txAccountIDFromRow(row model.ActivityRow) string {
|
||||||
|
if row.TxLog == nil || len(row.TxLog.Metadata) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var meta struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(row.TxLog.Metadata, &meta)
|
||||||
|
return meta.AccountID
|
||||||
|
}
|
||||||
|
|
||||||
func bold(s string) string {
|
func bold(s string) string {
|
||||||
return "<strong>" + templEscape(s) + "</strong>"
|
return "<strong>" + templEscape(s) + "</strong>"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue