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 {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue