feat: audit space activity
This commit is contained in:
parent
145eed9eef
commit
49bcc82934
16 changed files with 578 additions and 21 deletions
|
|
@ -20,6 +20,7 @@ type App struct {
|
|||
AccountService *service.AccountService
|
||||
TransactionService *service.TransactionService
|
||||
InviteService *service.InviteService
|
||||
AuditLogService *service.SpaceAuditLogService
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*App, error) {
|
||||
|
|
@ -43,10 +44,13 @@ func New(cfg *config.Config) (*App, error) {
|
|||
transactionRepository := repository.NewTransactionRepository(database)
|
||||
categoryRepository := repository.NewCategoryRepository(database)
|
||||
invitationRepository := repository.NewInvitationRepository(database)
|
||||
auditLogRepository := repository.NewSpaceAuditLogRepository(database)
|
||||
|
||||
// Services
|
||||
userService := service.NewUserService(userRepository)
|
||||
auditLogService := service.NewSpaceAuditLogService(auditLogRepository)
|
||||
spaceService := service.NewSpaceService(spaceRepository)
|
||||
spaceService.SetAuditLogger(auditLogService)
|
||||
accountService := service.NewAccountService(accountRepository)
|
||||
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
|
||||
emailService := service.NewEmailService(
|
||||
|
|
@ -67,7 +71,7 @@ func New(cfg *config.Config) (*App, error) {
|
|||
cfg.TokenMagicLinkExpiry,
|
||||
cfg.IsProduction(),
|
||||
)
|
||||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
|
||||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
|
||||
|
||||
return &App{
|
||||
Cfg: cfg,
|
||||
|
|
@ -79,6 +83,7 @@ func New(cfg *config.Config) (*App, error) {
|
|||
AccountService: accountService,
|
||||
TransactionService: transactionService,
|
||||
InviteService: inviteService,
|
||||
AuditLogService: auditLogService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -25,7 +25,7 @@ func newTestAuthHandler(dbi testutil.DBInfo) *authHandler {
|
|||
accountSvc := service.NewAccountService(accountRepo)
|
||||
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)
|
||||
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
|
||||
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
|
||||
return NewAuthHandler(authSvc, inviteSvc, spaceSvc)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type spaceHandler struct {
|
|||
accountService *service.AccountService
|
||||
transactionService *service.TransactionService
|
||||
inviteService *service.InviteService
|
||||
auditLogService *service.SpaceAuditLogService
|
||||
}
|
||||
|
||||
func NewSpaceHandler(
|
||||
|
|
@ -32,12 +33,14 @@ func NewSpaceHandler(
|
|||
accountService *service.AccountService,
|
||||
transactionService *service.TransactionService,
|
||||
inviteService *service.InviteService,
|
||||
auditLogService *service.SpaceAuditLogService,
|
||||
) *spaceHandler {
|
||||
return &spaceHandler{
|
||||
spaceService: spaceService,
|
||||
accountService: accountService,
|
||||
transactionService: transactionService,
|
||||
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)
|
||||
formProps.GeneralErr = "Something went wrong. Please try again."
|
||||
ui.Render(w, r, forms.UpdateSpace(formProps))
|
||||
|
|
@ -482,7 +485,7 @@ func (h *spaceHandler) HandleDeleteSpace(w http.ResponseWriter, r *http.Request)
|
|||
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)
|
||||
ui.RenderError(w, r, "Failed to delete space", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -608,7 +611,7 @@ func (h *spaceHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request
|
|||
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)
|
||||
ui.RenderError(w, r, "Failed to remove member", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -634,7 +637,7 @@ func (h *spaceHandler) HandleCancelInvite(w http.ResponseWriter, r *http.Request
|
|||
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)
|
||||
ui.RenderError(w, r, "Failed to cancel invitation", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -644,6 +647,57 @@ func (h *spaceHandler) HandleCancelInvite(w http.ResponseWriter, r *http.Request
|
|||
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) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
accountID := r.PathValue("accountID")
|
||||
|
|
|
|||
33
internal/model/space_audit_log.go
Normal file
33
internal/model/space_audit_log.go
Normal 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"`
|
||||
}
|
||||
60
internal/repository/space_audit_log.go
Normal file
60
internal/repository/space_audit_log.go
Normal 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
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
|
||||
homeH := handler.NewHomeHandler()
|
||||
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()
|
||||
|
||||
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.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.Get("/activity", spaceH.SpaceActivityPage).Name("page.app.spaces.space.activity")
|
||||
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/{userID}/remove", spaceH.HandleRemoveMember).Name("action.app.spaces.space.members.remove")
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func newTestApp(dbi testutil.DBInfo) *app.App {
|
|||
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)
|
||||
userSvc := service.NewUserService(userRepo)
|
||||
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
|
||||
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
|
||||
|
||||
return &app.App{
|
||||
Cfg: cfg,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
206
internal/ui/pages/space_activity.templ
Normal file
206
internal/ui/pages/space_activity.templ
Normal 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,16 @@ templ spaceSpecificSidebarContent(spaceID string) {
|
|||
<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.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue