diff --git a/internal/app/app.go b/internal/app/app.go index db346ca..8e7ad42 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 } diff --git a/internal/db/migrations/00009_create_space_audit_logs_table.sql b/internal/db/migrations/00009_create_space_audit_logs_table.sql new file mode 100644 index 0000000..028c19f --- /dev/null +++ b/internal/db/migrations/00009_create_space_audit_logs_table.sql @@ -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 diff --git a/internal/handler/auth_test.go b/internal/handler/auth_test.go index 36b4b2d..b8fc402 100644 --- a/internal/handler/auth_test.go +++ b/internal/handler/auth_test.go @@ -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) } diff --git a/internal/handler/space.go b/internal/handler/space.go index e71e401..0373a28 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -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") diff --git a/internal/model/space_audit_log.go b/internal/model/space_audit_log.go new file mode 100644 index 0000000..3037912 --- /dev/null +++ b/internal/model/space_audit_log.go @@ -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"` +} diff --git a/internal/repository/space_audit_log.go b/internal/repository/space_audit_log.go new file mode 100644 index 0000000..5176dbb --- /dev/null +++ b/internal/repository/space_audit_log.go @@ -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 +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 0b90909..b4e9874 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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") diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index 740ddf4..445190b 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -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, diff --git a/internal/service/auth.go b/internal/service/auth.go index 0a7b2ee..c69e6fe 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -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) } diff --git a/internal/service/invite.go b/internal/service/invite.go index 574b419..b0c780e 100644 --- a/internal/service/invite.go +++ b/internal/service/invite.go @@ -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 { diff --git a/internal/service/invite_test.go b/internal/service/invite_test.go index 9959a7a..98b3a99 100644 --- a/internal/service/invite_test.go +++ b/internal/service/invite_test.go @@ -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 diff --git a/internal/service/space.go b/internal/service/space.go index 423629d..e875a86 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -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) } diff --git a/internal/service/space_audit_log.go b/internal/service/space_audit_log.go new file mode 100644 index 0000000..5b23585 --- /dev/null +++ b/internal/service/space_audit_log.go @@ -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 +} diff --git a/internal/service/space_test.go b/internal/service/space_test.go index 88eb73f..e8899f2 100644 --- a/internal/service/space_test.go +++ b/internal/service/space_test.go @@ -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 diff --git a/internal/ui/pages/space_activity.templ b/internal/ui/pages/space_activity.templ new file mode 100644 index 0000000..73c4ac8 --- /dev/null +++ b/internal/ui/pages/space_activity.templ @@ -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), + ) { +
+ An audit log of changes to { props.SpaceName }. +
++ No activity yet. +
+ } else { ++ @templ.Raw(activityMessage(log)) +
++ { log.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } +
+