Compare commits
1 Commits
mai/pike/p
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b36247dfb9 |
@@ -9,8 +9,10 @@ import (
|
|||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userIDKey contextKey = "user_id"
|
userIDKey contextKey = "user_id"
|
||||||
tenantIDKey contextKey = "tenant_id"
|
tenantIDKey contextKey = "tenant_id"
|
||||||
|
ipKey contextKey = "ip_address"
|
||||||
|
userAgentKey contextKey = "user_agent"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
|
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
|
||||||
@@ -30,3 +32,23 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
|
|||||||
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
|
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
|
||||||
return id, ok
|
return id, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, ipKey, ip)
|
||||||
|
ctx = context.WithValue(ctx, userAgentKey, userAgent)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func IPFromContext(ctx context.Context) *string {
|
||||||
|
if v, ok := ctx.Value(ipKey).(string); ok && v != "" {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserAgentFromContext(ctx context.Context) *string {
|
||||||
|
if v, ok := ctx.Value(userAgentKey).(string); ok && v != "" {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
ctx = ContextWithTenantID(ctx, tenantID)
|
ctx = ContextWithTenantID(ctx, tenantID)
|
||||||
|
|
||||||
|
// Capture IP and user-agent for audit logging
|
||||||
|
ip := r.Header.Get("X-Forwarded-For")
|
||||||
|
if ip == "" {
|
||||||
|
ip = r.RemoteAddr
|
||||||
|
}
|
||||||
|
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
63
backend/internal/handlers/audit_log.go
Normal file
63
backend/internal/handlers/audit_log.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLogHandler struct {
|
||||||
|
svc *services.AuditService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditLogHandler(svc *services.AuditService) *AuditLogHandler {
|
||||||
|
return &AuditLogHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuditLogHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
page, _ := strconv.Atoi(q.Get("page"))
|
||||||
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||||
|
|
||||||
|
filter := services.AuditFilter{
|
||||||
|
EntityType: q.Get("entity_type"),
|
||||||
|
From: q.Get("from"),
|
||||||
|
To: q.Get("to"),
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if idStr := q.Get("entity_id"); idStr != "" {
|
||||||
|
if id, err := uuid.Parse(idStr); err == nil {
|
||||||
|
filter.EntityID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idStr := q.Get("user_id"); idStr != "" {
|
||||||
|
if id, err := uuid.Parse(idStr); err == nil {
|
||||||
|
filter.UserID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, total, err := h.svc.List(r.Context(), tenantID, filter)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch audit log")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"entries": entries,
|
||||||
|
"total": total,
|
||||||
|
"page": filter.Page,
|
||||||
|
"limit": filter.Limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline, err := h.deadlines.Create(tenantID, input)
|
deadline, err := h.deadlines.Create(r.Context(), tenantID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create deadline")
|
writeError(w, http.StatusInternalServerError, "failed to create deadline")
|
||||||
return
|
return
|
||||||
@@ -142,7 +142,7 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
|
deadline, err := h.deadlines.Update(r.Context(), tenantID, deadlineID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update deadline")
|
writeError(w, http.StatusInternalServerError, "failed to update deadline")
|
||||||
return
|
return
|
||||||
@@ -169,7 +169,7 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
|
deadline, err := h.deadlines.Complete(r.Context(), tenantID, deadlineID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to complete deadline")
|
writeError(w, http.StatusInternalServerError, "failed to complete deadline")
|
||||||
return
|
return
|
||||||
@@ -196,7 +196,7 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.deadlines.Delete(tenantID, deadlineID)
|
err = h.deadlines.Delete(r.Context(), tenantID, deadlineID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
22
backend/internal/models/audit_log.go
Normal file
22
backend/internal/models/audit_log.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"`
|
||||||
|
Action string `db:"action" json:"action"`
|
||||||
|
EntityType string `db:"entity_type" json:"entity_type"`
|
||||||
|
EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"`
|
||||||
|
OldValues *json.RawMessage `db:"old_values" json:"old_values,omitempty"`
|
||||||
|
NewValues *json.RawMessage `db:"new_values" json:"new_values,omitempty"`
|
||||||
|
IPAddress *string `db:"ip_address" json:"ip_address,omitempty"`
|
||||||
|
UserAgent *string `db:"user_agent" json:"user_agent,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
@@ -19,16 +19,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
tenantSvc := services.NewTenantService(db)
|
auditSvc := services.NewAuditService(db)
|
||||||
caseSvc := services.NewCaseService(db)
|
tenantSvc := services.NewTenantService(db, auditSvc)
|
||||||
partySvc := services.NewPartyService(db)
|
caseSvc := services.NewCaseService(db, auditSvc)
|
||||||
appointmentSvc := services.NewAppointmentService(db)
|
partySvc := services.NewPartyService(db, auditSvc)
|
||||||
|
appointmentSvc := services.NewAppointmentService(db, auditSvc)
|
||||||
holidaySvc := services.NewHolidayService(db)
|
holidaySvc := services.NewHolidayService(db)
|
||||||
deadlineSvc := services.NewDeadlineService(db)
|
deadlineSvc := services.NewDeadlineService(db, auditSvc)
|
||||||
deadlineRuleSvc := services.NewDeadlineRuleService(db)
|
deadlineRuleSvc := services.NewDeadlineRuleService(db)
|
||||||
calculator := services.NewDeadlineCalculator(holidaySvc)
|
calculator := services.NewDeadlineCalculator(holidaySvc)
|
||||||
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
|
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
|
||||||
documentSvc := services.NewDocumentService(db, storageCli)
|
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
|
||||||
|
|
||||||
// AI service (optional — only if API key is configured)
|
// AI service (optional — only if API key is configured)
|
||||||
var aiH *handlers.AIHandler
|
var aiH *handlers.AIHandler
|
||||||
@@ -40,10 +41,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
// Middleware
|
// Middleware
|
||||||
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
||||||
|
|
||||||
noteSvc := services.NewNoteService(db)
|
noteSvc := services.NewNoteService(db, auditSvc)
|
||||||
dashboardSvc := services.NewDashboardService(db)
|
dashboardSvc := services.NewDashboardService(db)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
auditH := handlers.NewAuditLogHandler(auditSvc)
|
||||||
tenantH := handlers.NewTenantHandler(tenantSvc)
|
tenantH := handlers.NewTenantHandler(tenantSvc)
|
||||||
caseH := handlers.NewCaseHandler(caseSvc)
|
caseH := handlers.NewCaseHandler(caseSvc)
|
||||||
partyH := handlers.NewPartyHandler(partySvc)
|
partyH := handlers.NewPartyHandler(partySvc)
|
||||||
@@ -123,6 +125,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
// Dashboard
|
// Dashboard
|
||||||
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
scoped.HandleFunc("GET /api/audit-log", auditH.List)
|
||||||
|
|
||||||
// Documents
|
// Documents
|
||||||
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
|
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
|
||||||
scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
|
scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AppointmentService struct {
|
type AppointmentService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
audit *AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
|
func NewAppointmentService(db *sqlx.DB, audit *AuditService) *AppointmentService {
|
||||||
return &AppointmentService{db: db}
|
return &AppointmentService{db: db, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppointmentFilter struct {
|
type AppointmentFilter struct {
|
||||||
@@ -86,6 +87,7 @@ func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating appointment: %w", err)
|
return fmt.Errorf("creating appointment: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "create", "appointment", &a.ID, nil, a)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment)
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("appointment not found")
|
return fmt.Errorf("appointment not found")
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "update", "appointment", &a.ID, nil, a)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,5 +134,6 @@ func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID)
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("appointment not found")
|
return fmt.Errorf("appointment not found")
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "delete", "appointment", &id, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
141
backend/internal/services/audit_service.go
Normal file
141
backend/internal/services/audit_service.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditService(db *sqlx.DB) *AuditService {
|
||||||
|
return &AuditService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log records an audit entry. It extracts tenant, user, IP, and user-agent from context.
|
||||||
|
// Errors are logged but not returned — audit logging must not break business operations.
|
||||||
|
func (s *AuditService) Log(ctx context.Context, action, entityType string, entityID *uuid.UUID, oldValues, newValues any) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
slog.Warn("audit: missing tenant_id in context", "action", action, "entity_type", entityType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID *uuid.UUID
|
||||||
|
if uid, ok := auth.UserFromContext(ctx); ok {
|
||||||
|
userID = &uid
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldJSON, newJSON *json.RawMessage
|
||||||
|
if oldValues != nil {
|
||||||
|
if b, err := json.Marshal(oldValues); err == nil {
|
||||||
|
raw := json.RawMessage(b)
|
||||||
|
oldJSON = &raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newValues != nil {
|
||||||
|
if b, err := json.Marshal(newValues); err == nil {
|
||||||
|
raw := json.RawMessage(b)
|
||||||
|
newJSON = &raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := auth.IPFromContext(ctx)
|
||||||
|
ua := auth.UserAgentFromContext(ctx)
|
||||||
|
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO audit_log (tenant_id, user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
tenantID, userID, action, entityType, entityID, oldJSON, newJSON, ip, ua)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("audit: failed to write log entry",
|
||||||
|
"error", err,
|
||||||
|
"action", action,
|
||||||
|
"entity_type", entityType,
|
||||||
|
"entity_id", entityID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditFilter holds query parameters for listing audit log entries.
|
||||||
|
type AuditFilter struct {
|
||||||
|
EntityType string
|
||||||
|
EntityID *uuid.UUID
|
||||||
|
UserID *uuid.UUID
|
||||||
|
From string // RFC3339 date
|
||||||
|
To string // RFC3339 date
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns paginated audit log entries for a tenant.
|
||||||
|
func (s *AuditService) List(ctx context.Context, tenantID uuid.UUID, filter AuditFilter) ([]models.AuditLog, int, error) {
|
||||||
|
if filter.Limit <= 0 {
|
||||||
|
filter.Limit = 50
|
||||||
|
}
|
||||||
|
if filter.Limit > 200 {
|
||||||
|
filter.Limit = 200
|
||||||
|
}
|
||||||
|
if filter.Page <= 0 {
|
||||||
|
filter.Page = 1
|
||||||
|
}
|
||||||
|
offset := (filter.Page - 1) * filter.Limit
|
||||||
|
|
||||||
|
where := "WHERE tenant_id = $1"
|
||||||
|
args := []any{tenantID}
|
||||||
|
argIdx := 2
|
||||||
|
|
||||||
|
if filter.EntityType != "" {
|
||||||
|
where += fmt.Sprintf(" AND entity_type = $%d", argIdx)
|
||||||
|
args = append(args, filter.EntityType)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filter.EntityID != nil {
|
||||||
|
where += fmt.Sprintf(" AND entity_id = $%d", argIdx)
|
||||||
|
args = append(args, *filter.EntityID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filter.UserID != nil {
|
||||||
|
where += fmt.Sprintf(" AND user_id = $%d", argIdx)
|
||||||
|
args = append(args, *filter.UserID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filter.From != "" {
|
||||||
|
where += fmt.Sprintf(" AND created_at >= $%d", argIdx)
|
||||||
|
args = append(args, filter.From)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filter.To != "" {
|
||||||
|
where += fmt.Sprintf(" AND created_at <= $%d", argIdx)
|
||||||
|
args = append(args, filter.To)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM audit_log "+where, args...); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("counting audit entries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("SELECT * FROM audit_log %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
|
||||||
|
where, argIdx, argIdx+1)
|
||||||
|
args = append(args, filter.Limit, offset)
|
||||||
|
|
||||||
|
var entries []models.AuditLog
|
||||||
|
if err := s.db.SelectContext(ctx, &entries, query, args...); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("listing audit entries: %w", err)
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []models.AuditLog{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, total, nil
|
||||||
|
}
|
||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CaseService struct {
|
type CaseService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
audit *AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCaseService(db *sqlx.DB) *CaseService {
|
func NewCaseService(db *sqlx.DB, audit *AuditService) *CaseService {
|
||||||
return &CaseService{db: db}
|
return &CaseService{db: db, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaseFilter struct {
|
type CaseFilter struct {
|
||||||
@@ -162,6 +163,9 @@ func (s *CaseService) Create(ctx context.Context, tenantID uuid.UUID, userID uui
|
|||||||
if err := s.db.GetContext(ctx, &c, "SELECT * FROM cases WHERE id = $1", id); err != nil {
|
if err := s.db.GetContext(ctx, &c, "SELECT * FROM cases WHERE id = $1", id); err != nil {
|
||||||
return nil, fmt.Errorf("fetching created case: %w", err)
|
return nil, fmt.Errorf("fetching created case: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.audit.Log(ctx, "create", "case", &id, nil, c)
|
||||||
|
|
||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +243,9 @@ func (s *CaseService) Update(ctx context.Context, tenantID, caseID uuid.UUID, us
|
|||||||
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM cases WHERE id = $1", caseID); err != nil {
|
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM cases WHERE id = $1", caseID); err != nil {
|
||||||
return nil, fmt.Errorf("fetching updated case: %w", err)
|
return nil, fmt.Errorf("fetching updated case: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.audit.Log(ctx, "update", "case", &caseID, current, updated)
|
||||||
|
|
||||||
return &updated, nil
|
return &updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +261,7 @@ func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, us
|
|||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil)
|
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil)
|
||||||
|
s.audit.Log(ctx, "delete", "case", &caseID, map[string]string{"status": "active"}, map[string]string{"status": "archived"})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,12 +14,13 @@ import (
|
|||||||
|
|
||||||
// DeadlineService handles CRUD operations for case deadlines
|
// DeadlineService handles CRUD operations for case deadlines
|
||||||
type DeadlineService struct {
|
type DeadlineService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
audit *AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDeadlineService creates a new deadline service
|
// NewDeadlineService creates a new deadline service
|
||||||
func NewDeadlineService(db *sqlx.DB) *DeadlineService {
|
func NewDeadlineService(db *sqlx.DB, audit *AuditService) *DeadlineService {
|
||||||
return &DeadlineService{db: db}
|
return &DeadlineService{db: db, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAll returns all deadlines for a tenant, ordered by due_date
|
// ListAll returns all deadlines for a tenant, ordered by due_date
|
||||||
@@ -87,7 +89,7 @@ type CreateDeadlineInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create inserts a new deadline
|
// Create inserts a new deadline
|
||||||
func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
|
func (s *DeadlineService) Create(ctx context.Context, tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
source := input.Source
|
source := input.Source
|
||||||
if source == "" {
|
if source == "" {
|
||||||
@@ -108,6 +110,7 @@ func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("creating deadline: %w", err)
|
return nil, fmt.Errorf("creating deadline: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "create", "deadline", &id, nil, d)
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +126,7 @@ type UpdateDeadlineInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update modifies an existing deadline
|
// Update modifies an existing deadline
|
||||||
func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
|
func (s *DeadlineService) Update(ctx context.Context, tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
|
||||||
// First check it exists and belongs to tenant
|
// First check it exists and belongs to tenant
|
||||||
existing, err := s.GetByID(tenantID, deadlineID)
|
existing, err := s.GetByID(tenantID, deadlineID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,11 +157,12 @@ func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDea
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("updating deadline: %w", err)
|
return nil, fmt.Errorf("updating deadline: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "update", "deadline", &deadlineID, existing, d)
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete marks a deadline as completed
|
// Complete marks a deadline as completed
|
||||||
func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
|
func (s *DeadlineService) Complete(ctx context.Context, tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
|
||||||
query := `UPDATE deadlines SET
|
query := `UPDATE deadlines SET
|
||||||
status = 'completed',
|
status = 'completed',
|
||||||
completed_at = $1,
|
completed_at = $1,
|
||||||
@@ -176,11 +180,12 @@ func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Dead
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("completing deadline: %w", err)
|
return nil, fmt.Errorf("completing deadline: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "update", "deadline", &deadlineID, map[string]string{"status": "pending"}, map[string]string{"status": "completed"})
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a deadline
|
// Delete removes a deadline
|
||||||
func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
|
func (s *DeadlineService) Delete(ctx context.Context, tenantID, deadlineID uuid.UUID) error {
|
||||||
query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2`
|
query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2`
|
||||||
result, err := s.db.Exec(query, deadlineID, tenantID)
|
result, err := s.db.Exec(query, deadlineID, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -193,5 +198,6 @@ func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("deadline not found")
|
return fmt.Errorf("deadline not found")
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "delete", "deadline", &deadlineID, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ const documentBucket = "kanzlai-documents"
|
|||||||
type DocumentService struct {
|
type DocumentService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
storage *StorageClient
|
storage *StorageClient
|
||||||
|
audit *AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService {
|
func NewDocumentService(db *sqlx.DB, storage *StorageClient, audit *AuditService) *DocumentService {
|
||||||
return &DocumentService{db: db, storage: storage}
|
return &DocumentService{db: db, storage: storage, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateDocumentInput struct {
|
type CreateDocumentInput struct {
|
||||||
@@ -97,6 +98,7 @@ func (s *DocumentService) Create(ctx context.Context, tenantID, caseID, userID u
|
|||||||
if err := s.db.GetContext(ctx, &doc, "SELECT * FROM documents WHERE id = $1", id); err != nil {
|
if err := s.db.GetContext(ctx, &doc, "SELECT * FROM documents WHERE id = $1", id); err != nil {
|
||||||
return nil, fmt.Errorf("fetching created document: %w", err)
|
return nil, fmt.Errorf("fetching created document: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "create", "document", &id, nil, doc)
|
||||||
return &doc, nil
|
return &doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +153,7 @@ func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uu
|
|||||||
// Log case event
|
// Log case event
|
||||||
createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted",
|
createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted",
|
||||||
fmt.Sprintf("Document deleted: %s", doc.Title), nil)
|
fmt.Sprintf("Document deleted: %s", doc.Title), nil)
|
||||||
|
s.audit.Log(ctx, "delete", "document", &docID, doc, nil)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type NoteService struct {
|
type NoteService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
audit *AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNoteService(db *sqlx.DB) *NoteService {
|
func NewNoteService(db *sqlx.DB, audit *AuditService) *NoteService {
|
||||||
return &NoteService{db: db}
|
return &NoteService{db: db, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByParent returns all notes for a given parent entity, scoped to tenant.
|
// ListByParent returns all notes for a given parent entity, scoped to tenant.
|
||||||
@@ -68,6 +69,7 @@ func (s *NoteService) Create(ctx context.Context, tenantID uuid.UUID, createdBy
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("creating note: %w", err)
|
return nil, fmt.Errorf("creating note: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "create", "note", &id, nil, n)
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ func (s *NoteService) Update(ctx context.Context, tenantID, noteID uuid.UUID, co
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("updating note: %w", err)
|
return nil, fmt.Errorf("updating note: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "update", "note", ¬eID, nil, n)
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@ func (s *NoteService) Delete(ctx context.Context, tenantID, noteID uuid.UUID) er
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return fmt.Errorf("note not found")
|
return fmt.Errorf("note not found")
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "delete", "note", ¬eID, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PartyService struct {
|
type PartyService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
audit *AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPartyService(db *sqlx.DB) *PartyService {
|
func NewPartyService(db *sqlx.DB, audit *AuditService) *PartyService {
|
||||||
return &PartyService{db: db}
|
return &PartyService{db: db, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePartyInput struct {
|
type CreatePartyInput struct {
|
||||||
@@ -79,6 +80,7 @@ func (s *PartyService) Create(ctx context.Context, tenantID, caseID uuid.UUID, u
|
|||||||
if err := s.db.GetContext(ctx, &party, "SELECT * FROM parties WHERE id = $1", id); err != nil {
|
if err := s.db.GetContext(ctx, &party, "SELECT * FROM parties WHERE id = $1", id); err != nil {
|
||||||
return nil, fmt.Errorf("fetching created party: %w", err)
|
return nil, fmt.Errorf("fetching created party: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "create", "party", &id, nil, party)
|
||||||
return &party, nil
|
return &party, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +137,7 @@ func (s *PartyService) Update(ctx context.Context, tenantID, partyID uuid.UUID,
|
|||||||
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM parties WHERE id = $1", partyID); err != nil {
|
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM parties WHERE id = $1", partyID); err != nil {
|
||||||
return nil, fmt.Errorf("fetching updated party: %w", err)
|
return nil, fmt.Errorf("fetching updated party: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "update", "party", &partyID, current, updated)
|
||||||
return &updated, nil
|
return &updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,5 +151,6 @@ func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID)
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "delete", "party", &partyID, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TenantService struct {
|
type TenantService struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
audit *AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTenantService(db *sqlx.DB) *TenantService {
|
func NewTenantService(db *sqlx.DB, audit *AuditService) *TenantService {
|
||||||
return &TenantService{db: db}
|
return &TenantService{db: db, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create creates a new tenant and assigns the creator as owner.
|
// Create creates a new tenant and assigns the creator as owner.
|
||||||
@@ -49,6 +50,7 @@ func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug
|
|||||||
return nil, fmt.Errorf("commit: %w", err)
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.audit.Log(ctx, "create", "tenant", &tenant.ID, nil, tenant)
|
||||||
return &tenant, nil
|
return &tenant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, e
|
|||||||
return nil, fmt.Errorf("invite user: %w", err)
|
return nil, fmt.Errorf("invite user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.audit.Log(ctx, "create", "membership", &tenantID, nil, ut)
|
||||||
return &ut, nil
|
return &ut, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +189,7 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update settings: %w", err)
|
return nil, fmt.Errorf("update settings: %w", err)
|
||||||
}
|
}
|
||||||
|
s.audit.Log(ctx, "update", "settings", &tenantID, nil, settings)
|
||||||
return &tenant, nil
|
return &tenant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,5 +227,6 @@ func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.
|
|||||||
return fmt.Errorf("remove member: %w", err)
|
return fmt.Errorf("remove member: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.audit.Log(ctx, "delete", "membership", &tenantID, map[string]any{"user_id": userID, "role": role}, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
ScrollText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
@@ -44,6 +45,7 @@ const TABS = [
|
|||||||
{ segment: "dokumente", label: "Dokumente", icon: FileText },
|
{ segment: "dokumente", label: "Dokumente", icon: FileText },
|
||||||
{ segment: "parteien", label: "Parteien", icon: Users },
|
{ segment: "parteien", label: "Parteien", icon: Users },
|
||||||
{ segment: "notizen", label: "Notizen", icon: StickyNote },
|
{ segment: "notizen", label: "Notizen", icon: StickyNote },
|
||||||
|
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const TAB_LABELS: Record<string, string> = {
|
const TAB_LABELS: Record<string, string> = {
|
||||||
@@ -52,6 +54,7 @@ const TAB_LABELS: Record<string, string> = {
|
|||||||
dokumente: "Dokumente",
|
dokumente: "Dokumente",
|
||||||
parteien: "Parteien",
|
parteien: "Parteien",
|
||||||
notizen: "Notizen",
|
notizen: "Notizen",
|
||||||
|
protokoll: "Protokoll",
|
||||||
};
|
};
|
||||||
|
|
||||||
function CaseDetailSkeleton() {
|
function CaseDetailSkeleton() {
|
||||||
|
|||||||
178
frontend/src/app/(app)/cases/[id]/protokoll/page.tsx
Normal file
178
frontend/src/app/(app)/cases/[id]/protokoll/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { AuditLogResponse } from "@/lib/types";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import { Loader2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
create: "Erstellt",
|
||||||
|
update: "Aktualisiert",
|
||||||
|
delete: "Geloescht",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
create: "bg-emerald-50 text-emerald-700",
|
||||||
|
update: "bg-blue-50 text-blue-700",
|
||||||
|
delete: "bg-red-50 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
|
case: "Akte",
|
||||||
|
deadline: "Frist",
|
||||||
|
appointment: "Termin",
|
||||||
|
document: "Dokument",
|
||||||
|
party: "Partei",
|
||||||
|
note: "Notiz",
|
||||||
|
settings: "Einstellungen",
|
||||||
|
membership: "Mitgliedschaft",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DiffPreview({
|
||||||
|
oldValues,
|
||||||
|
newValues,
|
||||||
|
}: {
|
||||||
|
oldValues?: Record<string, unknown>;
|
||||||
|
newValues?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
if (!oldValues && !newValues) return null;
|
||||||
|
|
||||||
|
const allKeys = new Set([
|
||||||
|
...Object.keys(oldValues ?? {}),
|
||||||
|
...Object.keys(newValues ?? {}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const changes: { key: string; from?: unknown; to?: unknown }[] = [];
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const oldVal = oldValues?.[key];
|
||||||
|
const newVal = newValues?.[key];
|
||||||
|
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
||||||
|
changes.push({ key, from: oldVal, to: newVal });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{changes.slice(0, 5).map((c) => (
|
||||||
|
<div key={c.key} className="flex items-baseline gap-2 text-xs">
|
||||||
|
<span className="font-medium text-neutral-500">{c.key}:</span>
|
||||||
|
{c.from !== undefined && (
|
||||||
|
<span className="rounded bg-red-50 px-1 text-red-600 line-through">
|
||||||
|
{String(c.from)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.to !== undefined && (
|
||||||
|
<span className="rounded bg-emerald-50 px-1 text-emerald-600">
|
||||||
|
{String(c.to)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{changes.length > 5 && (
|
||||||
|
<span className="text-xs text-neutral-400">
|
||||||
|
+{changes.length - 5} weitere Aenderungen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtokollPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const page = Number(searchParams.get("page")) || 1;
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["audit-log", id, page],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<AuditLogResponse>(
|
||||||
|
`/audit-log?entity_id=${id}&page=${page}&limit=50`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = data?.entries ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.ceil(total / 50);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-neutral-400">
|
||||||
|
Keine Protokolleintraege vorhanden.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="rounded-md border border-neutral-100 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${ACTION_COLORS[entry.action] ?? "bg-neutral-100 text-neutral-600"}`}
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[entry.action] ?? entry.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-neutral-700">
|
||||||
|
{ENTITY_LABELS[entry.entity_type] ?? entry.entity_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs text-neutral-400">
|
||||||
|
{format(new Date(entry.created_at), "d. MMM yyyy, HH:mm", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DiffPreview
|
||||||
|
oldValues={entry.old_values}
|
||||||
|
newValues={entry.new_values}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-neutral-400">
|
||||||
|
{total} Eintraege, Seite {page} von {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{page > 1 && (
|
||||||
|
<a
|
||||||
|
href={`?page=${page - 1}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3" /> Zurueck
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{page < totalPages && (
|
||||||
|
<a
|
||||||
|
href={`?page=${page + 1}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Weiter <ChevronRight className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -189,6 +189,27 @@ export interface Note {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: number;
|
||||||
|
tenant_id: string;
|
||||||
|
user_id?: string;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id?: string;
|
||||||
|
old_values?: Record<string, unknown>;
|
||||||
|
new_values?: Record<string, unknown>;
|
||||||
|
ip_address?: string;
|
||||||
|
user_agent?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogResponse {
|
||||||
|
entries: AuditLogEntry[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: string;
|
error: string;
|
||||||
status: number;
|
status: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user