feat: audit space activity

This commit is contained in:
juancwu 2026-05-03 23:10:31 +00:00
commit 49bcc82934
16 changed files with 578 additions and 21 deletions

View file

@ -20,6 +20,7 @@ type App struct {
AccountService *service.AccountService AccountService *service.AccountService
TransactionService *service.TransactionService TransactionService *service.TransactionService
InviteService *service.InviteService InviteService *service.InviteService
AuditLogService *service.SpaceAuditLogService
} }
func New(cfg *config.Config) (*App, error) { func New(cfg *config.Config) (*App, error) {
@ -43,10 +44,13 @@ func New(cfg *config.Config) (*App, error) {
transactionRepository := repository.NewTransactionRepository(database) transactionRepository := repository.NewTransactionRepository(database)
categoryRepository := repository.NewCategoryRepository(database) categoryRepository := repository.NewCategoryRepository(database)
invitationRepository := repository.NewInvitationRepository(database) invitationRepository := repository.NewInvitationRepository(database)
auditLogRepository := repository.NewSpaceAuditLogRepository(database)
// Services // Services
userService := service.NewUserService(userRepository) userService := service.NewUserService(userRepository)
auditLogService := service.NewSpaceAuditLogService(auditLogRepository)
spaceService := service.NewSpaceService(spaceRepository) spaceService := service.NewSpaceService(spaceRepository)
spaceService.SetAuditLogger(auditLogService)
accountService := service.NewAccountService(accountRepository) accountService := service.NewAccountService(accountRepository)
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService) transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
emailService := service.NewEmailService( emailService := service.NewEmailService(
@ -67,7 +71,7 @@ func New(cfg *config.Config) (*App, error) {
cfg.TokenMagicLinkExpiry, cfg.TokenMagicLinkExpiry,
cfg.IsProduction(), cfg.IsProduction(),
) )
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
return &App{ return &App{
Cfg: cfg, Cfg: cfg,
@ -79,6 +83,7 @@ func New(cfg *config.Config) (*App, error) {
AccountService: accountService, AccountService: accountService,
TransactionService: transactionService, TransactionService: transactionService,
InviteService: inviteService, InviteService: inviteService,
AuditLogService: auditLogService,
}, nil }, nil
} }

View file

@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE space_audit_logs (
id TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL,
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
target_email TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_space_audit_logs_space_id_created_at
ON space_audit_logs (space_id, created_at DESC);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE space_audit_logs;
-- +goose StatementEnd

View file

@ -25,7 +25,7 @@ func newTestAuthHandler(dbi testutil.DBInfo) *authHandler {
accountSvc := service.NewAccountService(accountRepo) accountSvc := service.NewAccountService(accountRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false) authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc) inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
return NewAuthHandler(authSvc, inviteSvc, spaceSvc) return NewAuthHandler(authSvc, inviteSvc, spaceSvc)
} }

View file

@ -25,6 +25,7 @@ type spaceHandler struct {
accountService *service.AccountService accountService *service.AccountService
transactionService *service.TransactionService transactionService *service.TransactionService
inviteService *service.InviteService inviteService *service.InviteService
auditLogService *service.SpaceAuditLogService
} }
func NewSpaceHandler( func NewSpaceHandler(
@ -32,12 +33,14 @@ func NewSpaceHandler(
accountService *service.AccountService, accountService *service.AccountService,
transactionService *service.TransactionService, transactionService *service.TransactionService,
inviteService *service.InviteService, inviteService *service.InviteService,
auditLogService *service.SpaceAuditLogService,
) *spaceHandler { ) *spaceHandler {
return &spaceHandler{ return &spaceHandler{
spaceService: spaceService, spaceService: spaceService,
accountService: accountService, accountService: accountService,
transactionService: transactionService, transactionService: transactionService,
inviteService: inviteService, inviteService: inviteService,
auditLogService: auditLogService,
} }
} }
@ -456,7 +459,7 @@ func (h *spaceHandler) HandleRenameSpace(w http.ResponseWriter, r *http.Request)
} }
} }
if err := h.spaceService.UpdateSpaceName(spaceID, nameInput); err != nil { if err := h.spaceService.UpdateSpaceName(spaceID, nameInput, user.ID); err != nil {
slog.Error("failed to rename space", "error", err, "space_id", spaceID) slog.Error("failed to rename space", "error", err, "space_id", spaceID)
formProps.GeneralErr = "Something went wrong. Please try again." formProps.GeneralErr = "Something went wrong. Please try again."
ui.Render(w, r, forms.UpdateSpace(formProps)) ui.Render(w, r, forms.UpdateSpace(formProps))
@ -482,7 +485,7 @@ func (h *spaceHandler) HandleDeleteSpace(w http.ResponseWriter, r *http.Request)
return return
} }
if err := h.spaceService.DeleteSpace(spaceID); err != nil { if err := h.spaceService.DeleteSpace(spaceID, user.ID); err != nil {
slog.Error("failed to delete space", "error", err, "space_id", spaceID) slog.Error("failed to delete space", "error", err, "space_id", spaceID)
ui.RenderError(w, r, "Failed to delete space", http.StatusInternalServerError) ui.RenderError(w, r, "Failed to delete space", http.StatusInternalServerError)
return return
@ -608,7 +611,7 @@ func (h *spaceHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request
return return
} }
if err := h.spaceService.RemoveMember(spaceID, userID); err != nil { if err := h.spaceService.RemoveMember(spaceID, userID, user.ID); err != nil {
slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID) slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID)
ui.RenderError(w, r, "Failed to remove member", http.StatusInternalServerError) ui.RenderError(w, r, "Failed to remove member", http.StatusInternalServerError)
return return
@ -634,7 +637,7 @@ func (h *spaceHandler) HandleCancelInvite(w http.ResponseWriter, r *http.Request
return return
} }
if err := h.inviteService.CancelInvite(token); err != nil { if err := h.inviteService.CancelInvite(token, user.ID); err != nil {
slog.Error("failed to cancel invite", "error", err, "token", token) slog.Error("failed to cancel invite", "error", err, "token", token)
ui.RenderError(w, r, "Failed to cancel invitation", http.StatusInternalServerError) ui.RenderError(w, r, "Failed to cancel invitation", http.StatusInternalServerError)
return return
@ -644,6 +647,57 @@ func (h *spaceHandler) HandleCancelInvite(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func (h *spaceHandler) SpaceActivityPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to load space", "error", err, "space_id", spaceID)
ui.Render(w, r, pages.NotFound())
return
}
const perPage = 25
page := 1
if p := strings.TrimSpace(r.URL.Query().Get("page")); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
total, err := h.auditLogService.Count(spaceID)
if err != nil {
slog.Error("failed to count audit logs", "error", err, "space_id", spaceID)
ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError)
return
}
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
logs, err := h.auditLogService.List(spaceID, perPage, (page-1)*perPage)
if err != nil {
slog.Error("failed to list audit logs", "error", err, "space_id", spaceID)
ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceActivityPage(pages.SpaceActivityPageProps{
SpaceID: space.ID,
SpaceName: space.Name,
Logs: logs,
CurrentPage: page,
TotalPages: totalPages,
TotalCount: total,
PerPage: perPage,
}))
}
func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.Request) { func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID") spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID") accountID := r.PathValue("accountID")

View file

@ -0,0 +1,33 @@
package model
import "time"
type SpaceAuditAction string
const (
SpaceAuditActionRenamed SpaceAuditAction = "space.renamed"
SpaceAuditActionDeleted SpaceAuditAction = "space.deleted"
SpaceAuditActionMemberInvited SpaceAuditAction = "member.invited"
SpaceAuditActionMemberJoined SpaceAuditAction = "member.joined"
SpaceAuditActionMemberRemoved SpaceAuditAction = "member.removed"
SpaceAuditActionInviteCancelled SpaceAuditAction = "invite.cancelled"
)
type SpaceAuditLog struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
ActorID *string `db:"actor_id"`
Action SpaceAuditAction `db:"action"`
TargetUserID *string `db:"target_user_id"`
TargetEmail *string `db:"target_email"`
Metadata []byte `db:"metadata"`
CreatedAt time.Time `db:"created_at"`
}
type SpaceAuditLogWithActor struct {
SpaceAuditLog
ActorName *string `db:"actor_name"`
ActorEmail *string `db:"actor_email"`
TargetUserName *string `db:"target_user_name"`
TargetUserEmail *string `db:"target_user_email"`
}

View file

@ -0,0 +1,60 @@
package repository
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
type SpaceAuditLogRepository interface {
Create(log *model.SpaceAuditLog) error
ListBySpace(spaceID string, limit, offset int) ([]*model.SpaceAuditLogWithActor, error)
CountBySpace(spaceID string) (int, error)
}
type spaceAuditLogRepository struct {
db *sqlx.DB
}
func NewSpaceAuditLogRepository(db *sqlx.DB) SpaceAuditLogRepository {
return &spaceAuditLogRepository{db: db}
}
func (r *spaceAuditLogRepository) Create(log *model.SpaceAuditLog) error {
query := `
INSERT INTO space_audit_logs
(id, space_id, actor_id, action, target_user_id, target_email, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`
metadata := log.Metadata
if len(metadata) == 0 {
metadata = []byte("{}")
}
_, err := r.db.Exec(query,
log.ID, log.SpaceID, log.ActorID, log.Action,
log.TargetUserID, log.TargetEmail, metadata, log.CreatedAt,
)
return err
}
func (r *spaceAuditLogRepository) ListBySpace(spaceID string, limit, offset int) ([]*model.SpaceAuditLogWithActor, error) {
query := `
SELECT
a.id, a.space_id, a.actor_id, a.action, a.target_user_id, a.target_email,
a.metadata, a.created_at,
actor.name AS actor_name, actor.email AS actor_email,
target.name AS target_user_name, target.email AS target_user_email
FROM space_audit_logs a
LEFT JOIN users actor ON actor.id = a.actor_id
LEFT JOIN users target ON target.id = a.target_user_id
WHERE a.space_id = $1
ORDER BY a.created_at DESC
LIMIT $2 OFFSET $3;`
var logs []*model.SpaceAuditLogWithActor
err := r.db.Select(&logs, query, spaceID, limit, offset)
return logs, err
}
func (r *spaceAuditLogRepository) CountBySpace(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM space_audit_logs WHERE space_id = $1;`, spaceID)
return count, err
}

View file

@ -19,7 +19,7 @@ func SetupRoutes(a *app.App) http.Handler {
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
homeH := handler.NewHomeHandler() homeH := handler.NewHomeHandler()
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService) settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.InviteService) spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.InviteService, a.AuditLogService)
redirectH := handler.NewRedirectHandler() redirectH := handler.NewRedirectHandler()
r := router.New() r := router.New()
@ -98,6 +98,7 @@ func SetupRoutes(a *app.App) http.Handler {
g.Get("/settings", spaceH.SpaceSettingsPage).Name("page.app.spaces.space.settings") g.Get("/settings", spaceH.SpaceSettingsPage).Name("page.app.spaces.space.settings")
g.Post("/settings/rename", spaceH.HandleRenameSpace).Name("action.app.spaces.space.settings.rename") g.Post("/settings/rename", spaceH.HandleRenameSpace).Name("action.app.spaces.space.settings.rename")
g.Post("/settings/delete", spaceH.HandleDeleteSpace).Name("action.app.spaces.space.settings.delete") g.Post("/settings/delete", spaceH.HandleDeleteSpace).Name("action.app.spaces.space.settings.delete")
g.Get("/activity", spaceH.SpaceActivityPage).Name("page.app.spaces.space.activity")
g.Get("/members", spaceH.SpaceMembersPage).Name("page.app.spaces.space.members") g.Get("/members", spaceH.SpaceMembersPage).Name("page.app.spaces.space.members")
g.Post("/members/invite", spaceH.HandleInviteMember).Name("action.app.spaces.space.members.invite") g.Post("/members/invite", spaceH.HandleInviteMember).Name("action.app.spaces.space.members.invite")
g.Post("/members/{userID}/remove", spaceH.HandleRemoveMember).Name("action.app.spaces.space.members.remove") g.Post("/members/{userID}/remove", spaceH.HandleRemoveMember).Name("action.app.spaces.space.members.remove")

View file

@ -29,7 +29,7 @@ func newTestApp(dbi testutil.DBInfo) *app.App {
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false) authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
userSvc := service.NewUserService(userRepo) userSvc := service.NewUserService(userRepo)
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc) inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
return &app.App{ return &app.App{
Cfg: cfg, Cfg: cfg,

View file

@ -364,7 +364,7 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
} }
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName); err != nil { if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName); err != nil {
if delErr := s.spaceService.DeleteSpace(space.ID); delErr != nil { if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
slog.Error("failed to roll back space after account creation error", slog.Error("failed to roll back space after account creation error",
"space_id", space.ID, "error", delErr) "space_id", space.ID, "error", delErr)
} }

View file

@ -21,14 +21,16 @@ type InviteService struct {
spaceRepo repository.SpaceRepository spaceRepo repository.SpaceRepository
userRepo repository.UserRepository userRepo repository.UserRepository
emailSvc *EmailService emailSvc *EmailService
auditSvc *SpaceAuditLogService
} }
func NewInviteService(ir repository.InvitationRepository, sr repository.SpaceRepository, ur repository.UserRepository, es *EmailService) *InviteService { func NewInviteService(ir repository.InvitationRepository, sr repository.SpaceRepository, ur repository.UserRepository, es *EmailService, audit *SpaceAuditLogService) *InviteService {
return &InviteService{ return &InviteService{
inviteRepo: ir, inviteRepo: ir,
spaceRepo: sr, spaceRepo: sr,
userRepo: ur, userRepo: ur,
emailSvc: es, emailSvc: es,
auditSvc: audit,
} }
} }
@ -96,6 +98,13 @@ func (s *InviteService) CreateInvite(spaceID, inviterID, email string) (*model.S
// Send Email // Send Email
go s.emailSvc.SendInvitationEmail(email, space.Name, inviterName, token) go s.emailSvc.SendInvitationEmail(email, space.Name, inviterName, token)
s.auditSvc.Record(RecordOptions{
SpaceID: spaceID,
ActorID: inviterID,
Action: model.SpaceAuditActionMemberInvited,
TargetEmail: email,
})
return invitation, nil return invitation, nil
} }
@ -120,10 +129,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) {
return "", err return "", err
} }
s.auditSvc.Record(RecordOptions{
SpaceID: invite.SpaceID,
ActorID: userID,
Action: model.SpaceAuditActionMemberJoined,
TargetUserID: userID,
TargetEmail: invite.Email,
Metadata: map[string]any{"invited_by": invite.InviterID},
})
return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted) return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted)
} }
func (s *InviteService) CancelInvite(token string) error { func (s *InviteService) CancelInvite(token, actorID string) error {
invite, err := s.inviteRepo.GetByToken(token) invite, err := s.inviteRepo.GetByToken(token)
if err != nil { if err != nil {
return err return err
@ -133,7 +151,18 @@ func (s *InviteService) CancelInvite(token string) error {
return errors.New("invitation is not pending") return errors.New("invitation is not pending")
} }
return s.inviteRepo.Delete(token) if err := s.inviteRepo.Delete(token); err != nil {
return err
}
s.auditSvc.Record(RecordOptions{
SpaceID: invite.SpaceID,
ActorID: actorID,
Action: model.SpaceAuditActionInviteCancelled,
TargetEmail: invite.Email,
})
return nil
} }
type InviteContext struct { type InviteContext struct {

View file

@ -14,7 +14,7 @@ func newTestInviteService(dbi testutil.DBInfo) *InviteService {
spaceRepo := repository.NewSpaceRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB)
userRepo := repository.NewUserRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB)
emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
return NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc) return NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
} }
func TestInviteService_CreateInvite(t *testing.T) { func TestInviteService_CreateInvite(t *testing.T) {
@ -64,7 +64,7 @@ func TestInviteService_CancelInvite(t *testing.T) {
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Cancel Space") space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Cancel Space")
invitation := testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "cancelee@example.com") invitation := testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "cancelee@example.com")
err := svc.CancelInvite(invitation.Token) err := svc.CancelInvite(invitation.Token, owner.ID)
require.NoError(t, err) require.NoError(t, err)
// Verify invitation is gone // Verify invitation is gone

View file

@ -13,6 +13,7 @@ const DefaultSpaceName = "My Space"
type SpaceService struct { type SpaceService struct {
spaceRepo repository.SpaceRepository spaceRepo repository.SpaceRepository
auditSvc *SpaceAuditLogService
} }
func NewSpaceService(spaceRepo repository.SpaceRepository) *SpaceService { func NewSpaceService(spaceRepo repository.SpaceRepository) *SpaceService {
@ -21,6 +22,13 @@ func NewSpaceService(spaceRepo repository.SpaceRepository) *SpaceService {
} }
} }
// SetAuditLogger wires the audit log service after construction. Kept separate from
// the constructor to avoid disturbing existing callers (especially tests) that don't
// care about auditing.
func (s *SpaceService) SetAuditLogger(audit *SpaceAuditLogService) {
s.auditSvc = audit
}
// CreateSpace creates a new space and sets the owner. // CreateSpace creates a new space and sets the owner.
func (s *SpaceService) CreateSpace(name string, ownerID string) (*model.Space, error) { func (s *SpaceService) CreateSpace(name string, ownerID string) (*model.Space, error) {
if name == "" { if name == "" {
@ -98,20 +106,62 @@ func (s *SpaceService) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfi
} }
// RemoveMember removes a member from a space. // RemoveMember removes a member from a space.
func (s *SpaceService) RemoveMember(spaceID, userID string) error { func (s *SpaceService) RemoveMember(spaceID, userID, actorID string) error {
return s.spaceRepo.RemoveMember(spaceID, userID) if err := s.spaceRepo.RemoveMember(spaceID, userID); err != nil {
return err
}
s.auditSvc.Record(RecordOptions{
SpaceID: spaceID,
ActorID: actorID,
Action: model.SpaceAuditActionMemberRemoved,
TargetUserID: userID,
})
return nil
} }
// UpdateSpaceName updates the name of a space. // UpdateSpaceName updates the name of a space.
func (s *SpaceService) UpdateSpaceName(spaceID, name string) error { func (s *SpaceService) UpdateSpaceName(spaceID, name, actorID string) error {
if name == "" { if name == "" {
return fmt.Errorf("space name cannot be empty") return fmt.Errorf("space name cannot be empty")
} }
return s.spaceRepo.UpdateName(spaceID, name) current, err := s.spaceRepo.ByID(spaceID)
if err != nil {
return err
}
oldName := current.Name
if err := s.spaceRepo.UpdateName(spaceID, name); err != nil {
return err
}
if oldName != name {
s.auditSvc.Record(RecordOptions{
SpaceID: spaceID,
ActorID: actorID,
Action: model.SpaceAuditActionRenamed,
Metadata: map[string]any{
"old_name": oldName,
"new_name": name,
},
})
}
return nil
} }
// DeleteSpace permanently deletes a space and all its associated data. // DeleteSpace permanently deletes a space and all its associated data.
func (s *SpaceService) DeleteSpace(spaceID string) error { func (s *SpaceService) DeleteSpace(spaceID, actorID string) error {
current, err := s.spaceRepo.ByID(spaceID)
if err != nil {
return err
}
// Record before deleting so the audit row is written while the space still exists.
// The audit table intentionally does not foreign-key space_id, so the entry survives.
s.auditSvc.Record(RecordOptions{
SpaceID: spaceID,
ActorID: actorID,
Action: model.SpaceAuditActionDeleted,
Metadata: map[string]any{
"space_name": current.Name,
},
})
return s.spaceRepo.Delete(spaceID) return s.spaceRepo.Delete(spaceID)
} }

View file

@ -0,0 +1,88 @@
package service
import (
"encoding/json"
"fmt"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type SpaceAuditLogService struct {
repo repository.SpaceAuditLogRepository
}
func NewSpaceAuditLogService(repo repository.SpaceAuditLogRepository) *SpaceAuditLogService {
return &SpaceAuditLogService{repo: repo}
}
type RecordOptions struct {
SpaceID string
ActorID string
Action model.SpaceAuditAction
TargetUserID string
TargetEmail string
Metadata map[string]any
}
// Record persists an audit entry. Failures are logged but never bubble up — auditing
// must not break the user-facing action that triggered it. A nil receiver is a no-op
// so tests can omit the dependency.
func (s *SpaceAuditLogService) Record(opts RecordOptions) {
if s == nil {
return
}
entry := &model.SpaceAuditLog{
ID: uuid.NewString(),
SpaceID: opts.SpaceID,
Action: opts.Action,
CreatedAt: time.Now(),
}
if opts.ActorID != "" {
actor := opts.ActorID
entry.ActorID = &actor
}
if opts.TargetUserID != "" {
t := opts.TargetUserID
entry.TargetUserID = &t
}
if opts.TargetEmail != "" {
e := opts.TargetEmail
entry.TargetEmail = &e
}
if len(opts.Metadata) > 0 {
raw, err := json.Marshal(opts.Metadata)
if err != nil {
slog.Error("failed to marshal audit metadata", "error", err, "action", opts.Action)
} else {
entry.Metadata = raw
}
}
if err := s.repo.Create(entry); err != nil {
slog.Error("failed to record space audit log",
"error", err,
"space_id", opts.SpaceID,
"action", opts.Action,
)
}
}
func (s *SpaceAuditLogService) List(spaceID string, limit, offset int) ([]*model.SpaceAuditLogWithActor, error) {
logs, err := s.repo.ListBySpace(spaceID, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to list audit logs: %w", err)
}
return logs, nil
}
func (s *SpaceAuditLogService) Count(spaceID string) (int, error) {
count, err := s.repo.CountBySpace(spaceID)
if err != nil {
return 0, fmt.Errorf("failed to count audit logs: %w", err)
}
return count, nil
}

View file

@ -128,7 +128,7 @@ func TestSpaceService_RemoveMember(t *testing.T) {
assert.True(t, isMember) assert.True(t, isMember)
// Remove member // Remove member
err = svc.RemoveMember(space.ID, member.ID) err = svc.RemoveMember(space.ID, member.ID, "")
require.NoError(t, err) require.NoError(t, err)
// Verify member was removed // Verify member was removed
@ -146,7 +146,7 @@ func TestSpaceService_UpdateSpaceName(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "rename@example.com", nil) user := testutil.CreateTestUser(t, dbi.DB, "rename@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Old Name") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Old Name")
err := svc.UpdateSpaceName(space.ID, "New Name") err := svc.UpdateSpaceName(space.ID, "New Name", "")
require.NoError(t, err) require.NoError(t, err)
// Verify name was updated by fetching the space // Verify name was updated by fetching the space

View file

@ -0,0 +1,206 @@
package pages
import "encoding/json"
import "fmt"
import "strings"
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
type SpaceActivityPageProps struct {
SpaceID string
SpaceName string
Logs []*model.SpaceAuditLogWithActor
CurrentPage int
TotalPages int
TotalCount int
PerPage int
}
templ SpaceActivityPage(props SpaceActivityPageProps) {
@layouts.AppWithBreadcrumb(
"Activity",
spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Activity"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div>
<h1 class="text-3xl font-bold">Activity</h1>
<p class="text-muted-foreground mt-2">
An audit log of changes to { props.SpaceName }.
</p>
</div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-0"}) {
if len(props.Logs) == 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)
}
</ol>
}
}
}
if props.TotalPages > 1 {
@activityPagination(props)
}
</div>
}
}
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:
@icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionDeleted:
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
case model.SpaceAuditActionMemberInvited:
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionMemberJoined:
@icon.UserPlus(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionMemberRemoved:
@icon.UserMinus(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionInviteCancelled:
@icon.X(icon.Props{Class: "size-4 text-muted-foreground"})
default:
@icon.History(icon.Props{Class: "size-4 text-muted-foreground"})
}
}
func actorLabel(log *model.SpaceAuditLogWithActor) string {
if log.ActorName != nil && *log.ActorName != "" {
return *log.ActorName
}
if log.ActorEmail != nil && *log.ActorEmail != "" {
return *log.ActorEmail
}
return "Someone"
}
func targetLabel(log *model.SpaceAuditLogWithActor) string {
if log.TargetUserName != nil && *log.TargetUserName != "" {
return *log.TargetUserName
}
if log.TargetUserEmail != nil && *log.TargetUserEmail != "" {
return *log.TargetUserEmail
}
if log.TargetEmail != nil && *log.TargetEmail != "" {
return *log.TargetEmail
}
return "a member"
}
// activityMessage returns a pre-escaped HTML string. Field values are escaped via
// templ.EscapeString; only the bold tags around them are intentional markup.
func activityMessage(log *model.SpaceAuditLogWithActor) string {
actor := bold(actorLabel(log))
target := bold(targetLabel(log))
switch log.Action {
case model.SpaceAuditActionRenamed:
var meta struct {
OldName string `json:"old_name"`
NewName string `json:"new_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
return fmt.Sprintf("%s renamed the space from %s to %s.",
actor, bold(meta.OldName), bold(meta.NewName))
case model.SpaceAuditActionDeleted:
var meta struct {
SpaceName string `json:"space_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.SpaceName
if name == "" {
name = "the space"
}
return fmt.Sprintf("%s deleted %s.", actor, bold(name))
case model.SpaceAuditActionMemberInvited:
return fmt.Sprintf("%s invited %s to join.", actor, target)
case model.SpaceAuditActionMemberJoined:
return fmt.Sprintf("%s joined the space.", target)
case model.SpaceAuditActionMemberRemoved:
return fmt.Sprintf("%s removed %s from the space.", actor, target)
case model.SpaceAuditActionInviteCancelled:
return fmt.Sprintf("%s cancelled the invitation for %s.", actor, target)
default:
return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action)))
}
}
func bold(s string) string {
return "<strong>" + templEscape(s) + "</strong>"
}
func templEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&#34;",
"'", "&#39;",
)
return r.Replace(s)
}
func activityPageURL(spaceID string, page int) string {
return fmt.Sprintf("%s?page=%d",
routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID), page)
}
templ activityPagination(props SpaceActivityPageProps) {
{{ p := pagination.CreatePagination(props.CurrentPage, props.TotalPages, 5) }}
@pagination.Pagination() {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Href: activityPageURL(props.SpaceID, p.CurrentPage-1),
Disabled: !p.HasPrevious,
Label: "Previous",
})
}
for _, page := range p.Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
Href: activityPageURL(props.SpaceID, page),
IsActive: page == p.CurrentPage,
}) {
{ fmt.Sprintf("%d", page) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Href: activityPageURL(props.SpaceID, p.CurrentPage+1),
Disabled: !p.HasNext,
Label: "Next",
})
}
}
}
}

View file

@ -74,6 +74,16 @@ templ spaceSpecificSidebarContent(spaceID string) {
<span>Members</span> <span>Members</span>
} }
} }
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID),
IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID),
Tooltip: "Activity",
}) {
@icon.History()
<span>Activity</span>
}
}
@sidebar.MenuItem() { @sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{ @sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID), Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID),