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
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
}

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)
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)
}

View file

@ -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")

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)
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")

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)
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,

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

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>
}
}
@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),