feat: aggregate account and transaction activity logs on space activity page

This commit is contained in:
juancwu 2026-05-03 23:56:06 +00:00
commit 9826068208
7 changed files with 135 additions and 38 deletions

View file

@ -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 {
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)
return
}
@ -691,9 +691,9 @@ func (h *spaceHandler) SpaceActivityPage(w http.ResponseWriter, r *http.Request)
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 {
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)
return
}
@ -701,7 +701,7 @@ func (h *spaceHandler) SpaceActivityPage(w http.ResponseWriter, r *http.Request)
ui.Render(w, r, pages.SpaceActivityPage(pages.SpaceActivityPageProps{
SpaceID: space.ID,
SpaceName: space.Name,
Logs: logs,
Rows: rows,
CurrentPage: page,
TotalPages: totalPages,
TotalCount: total,

View file

@ -2,15 +2,15 @@ package model
import "time"
// AccountActivityRow is a unified row representing either an account-scoped space
// audit entry or a transaction audit entry that belongs to the account. Exactly one
// of SpaceLog / TxLog is set.
type AccountActivityRow struct {
// ActivityRow is a unified row representing either a space-scoped audit entry or a
// transaction audit entry. Exactly one of SpaceLog / TxLog is set. Used by both the
// account-level and space-level activity feeds.
type ActivityRow struct {
SpaceLog *SpaceAuditLogWithActor
TxLog *TransactionAuditLogWithActor
}
func (r AccountActivityRow) Timestamp() time.Time {
func (r ActivityRow) Timestamp() time.Time {
if r.SpaceLog != nil {
return r.SpaceLog.CreatedAt
}

View file

@ -11,6 +11,8 @@ type TransactionAuditLogRepository interface {
CountByTransaction(transactionID string) (int, error)
ListByAccount(accountID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error)
CountByAccount(accountID string) (int, error)
ListBySpace(spaceID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error)
CountBySpace(spaceID string) (int, error)
}
type transactionAuditLogRepository struct {
@ -88,3 +90,38 @@ func (r *transactionAuditLogRepository) CountByAccount(accountID string) (int, e
accountID)
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
}

View file

@ -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)
// from each side and merge in Go. Audit volume per account is low, so the simplicity
// 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 {
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)
}
rows := make([]model.AccountActivityRow, 0, len(spaceLogs)+len(txLogs))
rows := make([]model.ActivityRow, 0, len(spaceLogs)+len(txLogs))
for _, l := range spaceLogs {
rows = append(rows, model.AccountActivityRow{SpaceLog: l})
rows = append(rows, model.ActivityRow{SpaceLog: l})
}
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 {
return rows[i].Timestamp().After(rows[j].Timestamp())
@ -75,3 +75,57 @@ func (s *AccountActivityService) Count(accountID string) (int, error) {
}
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
}

View file

@ -248,7 +248,11 @@ func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transacti
TransactionID: input.TransactionID,
ActorID: input.ActorID,
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
@ -314,7 +318,11 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra
TransactionID: input.TransactionID,
ActorID: input.ActorID,
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

View file

@ -17,7 +17,7 @@ type SpaceAccountActivityPageProps struct {
SpaceName string
AccountID string
AccountName string
Rows []model.AccountActivityRow
Rows []model.ActivityRow
CurrentPage int
TotalPages 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 {
<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">

View file

@ -14,7 +14,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
type SpaceActivityPageProps struct {
SpaceID string
SpaceName string
Logs []*model.SpaceAuditLogWithActor
Rows []model.ActivityRow
CurrentPage int
TotalPages int
TotalCount int
@ -37,14 +37,14 @@ templ SpaceActivityPage(props SpaceActivityPageProps) {
</div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@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">
No activity yet.
</p>
} else {
<ol class="divide-y">
for _, log := range props.Logs {
@activityRow(log)
for _, row := range props.Rows {
@accountActivityRow(props.SpaceID, txAccountIDFromRow(row), row)
}
</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) {
switch action {
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 {
return "<strong>" + templEscape(s) + "</strong>"
}