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
|
AccountService *service.AccountService
|
||||||
TransactionService *service.TransactionService
|
TransactionService *service.TransactionService
|
||||||
InviteService *service.InviteService
|
InviteService *service.InviteService
|
||||||
|
AuditLogService *service.SpaceAuditLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
func New(cfg *config.Config) (*App, error) {
|
||||||
|
|
@ -43,10 +44,13 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
transactionRepository := repository.NewTransactionRepository(database)
|
transactionRepository := repository.NewTransactionRepository(database)
|
||||||
categoryRepository := repository.NewCategoryRepository(database)
|
categoryRepository := repository.NewCategoryRepository(database)
|
||||||
invitationRepository := repository.NewInvitationRepository(database)
|
invitationRepository := repository.NewInvitationRepository(database)
|
||||||
|
auditLogRepository := repository.NewSpaceAuditLogRepository(database)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
|
auditLogService := service.NewSpaceAuditLogService(auditLogRepository)
|
||||||
spaceService := service.NewSpaceService(spaceRepository)
|
spaceService := service.NewSpaceService(spaceRepository)
|
||||||
|
spaceService.SetAuditLogger(auditLogService)
|
||||||
accountService := service.NewAccountService(accountRepository)
|
accountService := service.NewAccountService(accountRepository)
|
||||||
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
|
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
|
||||||
emailService := service.NewEmailService(
|
emailService := service.NewEmailService(
|
||||||
|
|
@ -67,7 +71,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
cfg.TokenMagicLinkExpiry,
|
cfg.TokenMagicLinkExpiry,
|
||||||
cfg.IsProduction(),
|
cfg.IsProduction(),
|
||||||
)
|
)
|
||||||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
|
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
|
@ -79,6 +83,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
AccountService: accountService,
|
AccountService: accountService,
|
||||||
TransactionService: transactionService,
|
TransactionService: transactionService,
|
||||||
InviteService: inviteService,
|
InviteService: inviteService,
|
||||||
|
AuditLogService: auditLogService,
|
||||||
}, nil
|
}, 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)
|
accountSvc := service.NewAccountService(accountRepo)
|
||||||
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
|
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)
|
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)
|
return NewAuthHandler(authSvc, inviteSvc, spaceSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ type spaceHandler struct {
|
||||||
accountService *service.AccountService
|
accountService *service.AccountService
|
||||||
transactionService *service.TransactionService
|
transactionService *service.TransactionService
|
||||||
inviteService *service.InviteService
|
inviteService *service.InviteService
|
||||||
|
auditLogService *service.SpaceAuditLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpaceHandler(
|
func NewSpaceHandler(
|
||||||
|
|
@ -32,12 +33,14 @@ func NewSpaceHandler(
|
||||||
accountService *service.AccountService,
|
accountService *service.AccountService,
|
||||||
transactionService *service.TransactionService,
|
transactionService *service.TransactionService,
|
||||||
inviteService *service.InviteService,
|
inviteService *service.InviteService,
|
||||||
|
auditLogService *service.SpaceAuditLogService,
|
||||||
) *spaceHandler {
|
) *spaceHandler {
|
||||||
return &spaceHandler{
|
return &spaceHandler{
|
||||||
spaceService: spaceService,
|
spaceService: spaceService,
|
||||||
accountService: accountService,
|
accountService: accountService,
|
||||||
transactionService: transactionService,
|
transactionService: transactionService,
|
||||||
inviteService: inviteService,
|
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)
|
slog.Error("failed to rename space", "error", err, "space_id", spaceID)
|
||||||
formProps.GeneralErr = "Something went wrong. Please try again."
|
formProps.GeneralErr = "Something went wrong. Please try again."
|
||||||
ui.Render(w, r, forms.UpdateSpace(formProps))
|
ui.Render(w, r, forms.UpdateSpace(formProps))
|
||||||
|
|
@ -482,7 +485,7 @@ func (h *spaceHandler) HandleDeleteSpace(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
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)
|
slog.Error("failed to delete space", "error", err, "space_id", spaceID)
|
||||||
ui.RenderError(w, r, "Failed to delete space", http.StatusInternalServerError)
|
ui.RenderError(w, r, "Failed to delete space", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -608,7 +611,7 @@ func (h *spaceHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request
|
||||||
return
|
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)
|
slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID)
|
||||||
ui.RenderError(w, r, "Failed to remove member", http.StatusInternalServerError)
|
ui.RenderError(w, r, "Failed to remove member", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -634,7 +637,7 @@ func (h *spaceHandler) HandleCancelInvite(w http.ResponseWriter, r *http.Request
|
||||||
return
|
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)
|
slog.Error("failed to cancel invite", "error", err, "token", token)
|
||||||
ui.RenderError(w, r, "Failed to cancel invitation", http.StatusInternalServerError)
|
ui.RenderError(w, r, "Failed to cancel invitation", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -644,6 +647,57 @@ func (h *spaceHandler) HandleCancelInvite(w http.ResponseWriter, r *http.Request
|
||||||
w.WriteHeader(http.StatusOK)
|
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) {
|
func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
accountID := r.PathValue("accountID")
|
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)
|
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
|
||||||
homeH := handler.NewHomeHandler()
|
homeH := handler.NewHomeHandler()
|
||||||
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
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()
|
redirectH := handler.NewRedirectHandler()
|
||||||
|
|
||||||
r := router.New()
|
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.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/rename", spaceH.HandleRenameSpace).Name("action.app.spaces.space.settings.rename")
|
||||||
g.Post("/settings/delete", spaceH.HandleDeleteSpace).Name("action.app.spaces.space.settings.delete")
|
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.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/invite", spaceH.HandleInviteMember).Name("action.app.spaces.space.members.invite")
|
||||||
g.Post("/members/{userID}/remove", spaceH.HandleRemoveMember).Name("action.app.spaces.space.members.remove")
|
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)
|
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)
|
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
|
||||||
userSvc := service.NewUserService(userRepo)
|
userSvc := service.NewUserService(userRepo)
|
||||||
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
|
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
|
||||||
|
|
||||||
return &app.App{
|
return &app.App{
|
||||||
Cfg: cfg,
|
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 _, 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",
|
slog.Error("failed to roll back space after account creation error",
|
||||||
"space_id", space.ID, "error", delErr)
|
"space_id", space.ID, "error", delErr)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,16 @@ type InviteService struct {
|
||||||
spaceRepo repository.SpaceRepository
|
spaceRepo repository.SpaceRepository
|
||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
emailSvc *EmailService
|
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{
|
return &InviteService{
|
||||||
inviteRepo: ir,
|
inviteRepo: ir,
|
||||||
spaceRepo: sr,
|
spaceRepo: sr,
|
||||||
userRepo: ur,
|
userRepo: ur,
|
||||||
emailSvc: es,
|
emailSvc: es,
|
||||||
|
auditSvc: audit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +98,13 @@ func (s *InviteService) CreateInvite(spaceID, inviterID, email string) (*model.S
|
||||||
// Send Email
|
// Send Email
|
||||||
go s.emailSvc.SendInvitationEmail(email, space.Name, inviterName, token)
|
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
|
return invitation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,10 +129,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) {
|
||||||
return "", err
|
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)
|
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)
|
invite, err := s.inviteRepo.GetByToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -133,7 +151,18 @@ func (s *InviteService) CancelInvite(token string) error {
|
||||||
return errors.New("invitation is not pending")
|
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 {
|
type InviteContext struct {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ func newTestInviteService(dbi testutil.DBInfo) *InviteService {
|
||||||
spaceRepo := repository.NewSpaceRepository(dbi.DB)
|
spaceRepo := repository.NewSpaceRepository(dbi.DB)
|
||||||
userRepo := repository.NewUserRepository(dbi.DB)
|
userRepo := repository.NewUserRepository(dbi.DB)
|
||||||
emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
|
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) {
|
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")
|
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Cancel Space")
|
||||||
invitation := testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "cancelee@example.com")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify invitation is gone
|
// Verify invitation is gone
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const DefaultSpaceName = "My Space"
|
||||||
|
|
||||||
type SpaceService struct {
|
type SpaceService struct {
|
||||||
spaceRepo repository.SpaceRepository
|
spaceRepo repository.SpaceRepository
|
||||||
|
auditSvc *SpaceAuditLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpaceService(spaceRepo repository.SpaceRepository) *SpaceService {
|
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.
|
// CreateSpace creates a new space and sets the owner.
|
||||||
func (s *SpaceService) CreateSpace(name string, ownerID string) (*model.Space, error) {
|
func (s *SpaceService) CreateSpace(name string, ownerID string) (*model.Space, error) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|
@ -98,20 +106,62 @@ func (s *SpaceService) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfi
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveMember removes a member from a space.
|
// RemoveMember removes a member from a space.
|
||||||
func (s *SpaceService) RemoveMember(spaceID, userID string) error {
|
func (s *SpaceService) RemoveMember(spaceID, userID, actorID string) error {
|
||||||
return s.spaceRepo.RemoveMember(spaceID, userID)
|
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.
|
// 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 == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("space name cannot be empty")
|
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.
|
// 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)
|
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)
|
assert.True(t, isMember)
|
||||||
|
|
||||||
// Remove member
|
// Remove member
|
||||||
err = svc.RemoveMember(space.ID, member.ID)
|
err = svc.RemoveMember(space.ID, member.ID, "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify member was removed
|
// Verify member was removed
|
||||||
|
|
@ -146,7 +146,7 @@ func TestSpaceService_UpdateSpaceName(t *testing.T) {
|
||||||
user := testutil.CreateTestUser(t, dbi.DB, "rename@example.com", nil)
|
user := testutil.CreateTestUser(t, dbi.DB, "rename@example.com", nil)
|
||||||
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Old Name")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify name was updated by fetching the space
|
// 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>
|
<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.MenuItem() {
|
||||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||||
Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID),
|
Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue