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

@ -364,7 +364,7 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
}
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",
"space_id", space.ID, "error", delErr)
}

View file

@ -21,14 +21,16 @@ type InviteService struct {
spaceRepo repository.SpaceRepository
userRepo repository.UserRepository
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{
inviteRepo: ir,
spaceRepo: sr,
userRepo: ur,
emailSvc: es,
auditSvc: audit,
}
}
@ -96,6 +98,13 @@ func (s *InviteService) CreateInvite(spaceID, inviterID, email string) (*model.S
// Send Email
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
}
@ -120,10 +129,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) {
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)
}
func (s *InviteService) CancelInvite(token string) error {
func (s *InviteService) CancelInvite(token, actorID string) error {
invite, err := s.inviteRepo.GetByToken(token)
if err != nil {
return err
@ -133,7 +151,18 @@ func (s *InviteService) CancelInvite(token string) error {
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 {

View file

@ -14,7 +14,7 @@ func newTestInviteService(dbi testutil.DBInfo) *InviteService {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
userRepo := repository.NewUserRepository(dbi.DB)
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) {
@ -64,7 +64,7 @@ func TestInviteService_CancelInvite(t *testing.T) {
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Cancel Space")
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)
// Verify invitation is gone

View file

@ -13,6 +13,7 @@ const DefaultSpaceName = "My Space"
type SpaceService struct {
spaceRepo repository.SpaceRepository
auditSvc *SpaceAuditLogService
}
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.
func (s *SpaceService) CreateSpace(name string, ownerID string) (*model.Space, error) {
if name == "" {
@ -98,20 +106,62 @@ func (s *SpaceService) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfi
}
// RemoveMember removes a member from a space.
func (s *SpaceService) RemoveMember(spaceID, userID string) error {
return s.spaceRepo.RemoveMember(spaceID, userID)
func (s *SpaceService) RemoveMember(spaceID, userID, actorID string) error {
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.
func (s *SpaceService) UpdateSpaceName(spaceID, name string) error {
func (s *SpaceService) UpdateSpaceName(spaceID, name, actorID string) error {
if name == "" {
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.
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)
}

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)
// Remove member
err = svc.RemoveMember(space.ID, member.ID)
err = svc.RemoveMember(space.ID, member.ID, "")
require.NoError(t, err)
// Verify member was removed
@ -146,7 +146,7 @@ func TestSpaceService_UpdateSpaceName(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "rename@example.com", nil)
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)
// Verify name was updated by fetching the space