diff --git a/internal/handler/space.go b/internal/handler/space.go index 5f10b13..869525a 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -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, diff --git a/internal/model/account_activity.go b/internal/model/account_activity.go index 6469013..0610daf 100644 --- a/internal/model/account_activity.go +++ b/internal/model/account_activity.go @@ -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 } diff --git a/internal/repository/transaction_audit_log.go b/internal/repository/transaction_audit_log.go index 592dee9..c743755 100644 --- a/internal/repository/transaction_audit_log.go +++ b/internal/repository/transaction_audit_log.go @@ -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 +} diff --git a/internal/service/account_activity.go b/internal/service/account_activity.go index 95f12e0..0cf9618 100644 --- a/internal/service/account_activity.go +++ b/internal/service/account_activity.go @@ -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 +} diff --git a/internal/service/transaction.go b/internal/service/transaction.go index e95692e..9e0d33b 100644 --- a/internal/service/transaction.go +++ b/internal/service/transaction.go @@ -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 diff --git a/internal/ui/pages/space_account_activity.templ b/internal/ui/pages/space_account_activity.templ index bb1912f..dd2123a 100644 --- a/internal/ui/pages/space_account_activity.templ +++ b/internal/ui/pages/space_account_activity.templ @@ -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 {
No activity yet.
} else {- @templ.Raw(activityMessage(log)) -
-- { log.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } -
-