feat: audit space activity
This commit is contained in:
parent
145eed9eef
commit
49bcc82934
16 changed files with 578 additions and 21 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
88
internal/service/space_audit_log.go
Normal file
88
internal/service/space_audit_log.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue