diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index a42a6c3..abab9cf 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -9,8 +9,10 @@ import ( type contextKey string const ( - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" + userIDKey contextKey = "user_id" + tenantIDKey contextKey = "tenant_id" + ipKey contextKey = "ip_address" + userAgentKey contextKey = "user_agent" ) 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) 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 +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 4f31eb6..1321637 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -46,6 +46,13 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } 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)) }) } diff --git a/backend/internal/handlers/audit_log.go b/backend/internal/handlers/audit_log.go new file mode 100644 index 0000000..8ad3e58 --- /dev/null +++ b/backend/internal/handlers/audit_log.go @@ -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, + }) +} diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index 5b2b265..36c5892 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -113,7 +113,7 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) { return } - deadline, err := h.deadlines.Create(tenantID, input) + deadline, err := h.deadlines.Create(r.Context(), tenantID, input) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create deadline") return @@ -142,7 +142,7 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) { return } - deadline, err := h.deadlines.Update(tenantID, deadlineID, input) + deadline, err := h.deadlines.Update(r.Context(), tenantID, deadlineID, input) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update deadline") return @@ -169,7 +169,7 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) { return } - deadline, err := h.deadlines.Complete(tenantID, deadlineID) + deadline, err := h.deadlines.Complete(r.Context(), tenantID, deadlineID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to complete deadline") return @@ -196,7 +196,7 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { return } - err = h.deadlines.Delete(tenantID, deadlineID) + err = h.deadlines.Delete(r.Context(), tenantID, deadlineID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return diff --git a/backend/internal/models/audit_log.go b/backend/internal/models/audit_log.go new file mode 100644 index 0000000..b0135a0 --- /dev/null +++ b/backend/internal/models/audit_log.go @@ -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"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3c6f8a5..40dce6f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -19,16 +19,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se mux := http.NewServeMux() // Services - tenantSvc := services.NewTenantService(db) - caseSvc := services.NewCaseService(db) - partySvc := services.NewPartyService(db) - appointmentSvc := services.NewAppointmentService(db) + auditSvc := services.NewAuditService(db) + tenantSvc := services.NewTenantService(db, auditSvc) + caseSvc := services.NewCaseService(db, auditSvc) + partySvc := services.NewPartyService(db, auditSvc) + appointmentSvc := services.NewAppointmentService(db, auditSvc) holidaySvc := services.NewHolidayService(db) - deadlineSvc := services.NewDeadlineService(db) + deadlineSvc := services.NewDeadlineService(db, auditSvc) deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) 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) var aiH *handlers.AIHandler @@ -40,10 +41,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Middleware tenantResolver := auth.NewTenantResolver(tenantSvc) - noteSvc := services.NewNoteService(db) + noteSvc := services.NewNoteService(db, auditSvc) dashboardSvc := services.NewDashboardService(db) // Handlers + auditH := handlers.NewAuditLogHandler(auditSvc) tenantH := handlers.NewTenantHandler(tenantSvc) caseH := handlers.NewCaseHandler(caseSvc) partyH := handlers.NewPartyHandler(partySvc) @@ -123,6 +125,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Dashboard scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) + // Audit log + scoped.HandleFunc("GET /api/audit-log", auditH.List) + // Documents scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload) diff --git a/backend/internal/services/appointment_service.go b/backend/internal/services/appointment_service.go index e9a246c..03126fa 100644 --- a/backend/internal/services/appointment_service.go +++ b/backend/internal/services/appointment_service.go @@ -12,11 +12,12 @@ import ( ) type AppointmentService struct { - db *sqlx.DB + db *sqlx.DB + audit *AuditService } -func NewAppointmentService(db *sqlx.DB) *AppointmentService { - return &AppointmentService{db: db} +func NewAppointmentService(db *sqlx.DB, audit *AuditService) *AppointmentService { + return &AppointmentService{db: db, audit: audit} } type AppointmentFilter struct { @@ -86,6 +87,7 @@ func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment) if err != nil { return fmt.Errorf("creating appointment: %w", err) } + s.audit.Log(ctx, "create", "appointment", &a.ID, nil, a) return nil } @@ -116,6 +118,7 @@ func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment) if rows == 0 { return fmt.Errorf("appointment not found") } + s.audit.Log(ctx, "update", "appointment", &a.ID, nil, a) return nil } @@ -131,5 +134,6 @@ func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID) if rows == 0 { return fmt.Errorf("appointment not found") } + s.audit.Log(ctx, "delete", "appointment", &id, nil, nil) return nil } diff --git a/backend/internal/services/audit_service.go b/backend/internal/services/audit_service.go new file mode 100644 index 0000000..fe713ea --- /dev/null +++ b/backend/internal/services/audit_service.go @@ -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 +} diff --git a/backend/internal/services/case_service.go b/backend/internal/services/case_service.go index dbed424..f636ffc 100644 --- a/backend/internal/services/case_service.go +++ b/backend/internal/services/case_service.go @@ -13,11 +13,12 @@ import ( ) type CaseService struct { - db *sqlx.DB + db *sqlx.DB + audit *AuditService } -func NewCaseService(db *sqlx.DB) *CaseService { - return &CaseService{db: db} +func NewCaseService(db *sqlx.DB, audit *AuditService) *CaseService { + return &CaseService{db: db, audit: audit} } 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 { return nil, fmt.Errorf("fetching created case: %w", err) } + + s.audit.Log(ctx, "create", "case", &id, nil, c) + 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 { return nil, fmt.Errorf("fetching updated case: %w", err) } + + s.audit.Log(ctx, "update", "case", &caseID, current, updated) + return &updated, nil } @@ -254,6 +261,7 @@ func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, us return sql.ErrNoRows } 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 } diff --git a/backend/internal/services/deadline_service.go b/backend/internal/services/deadline_service.go index 61eebd4..0b0fca7 100644 --- a/backend/internal/services/deadline_service.go +++ b/backend/internal/services/deadline_service.go @@ -1,6 +1,7 @@ package services import ( + "context" "database/sql" "fmt" "time" @@ -13,12 +14,13 @@ import ( // DeadlineService handles CRUD operations for case deadlines type DeadlineService struct { - db *sqlx.DB + db *sqlx.DB + audit *AuditService } // NewDeadlineService creates a new deadline service -func NewDeadlineService(db *sqlx.DB) *DeadlineService { - return &DeadlineService{db: db} +func NewDeadlineService(db *sqlx.DB, audit *AuditService) *DeadlineService { + return &DeadlineService{db: db, audit: audit} } // ListAll returns all deadlines for a tenant, ordered by due_date @@ -87,7 +89,7 @@ type CreateDeadlineInput struct { } // 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() source := input.Source if source == "" { @@ -108,6 +110,7 @@ func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput) if err != nil { return nil, fmt.Errorf("creating deadline: %w", err) } + s.audit.Log(ctx, "create", "deadline", &id, nil, d) return &d, nil } @@ -123,7 +126,7 @@ type UpdateDeadlineInput struct { } // 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 existing, err := s.GetByID(tenantID, deadlineID) if err != nil { @@ -154,11 +157,12 @@ func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDea if err != nil { return nil, fmt.Errorf("updating deadline: %w", err) } + s.audit.Log(ctx, "update", "deadline", &deadlineID, existing, d) return &d, nil } // 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 status = 'completed', 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) } + s.audit.Log(ctx, "update", "deadline", &deadlineID, map[string]string{"status": "pending"}, map[string]string{"status": "completed"}) return &d, nil } // 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` result, err := s.db.Exec(query, deadlineID, tenantID) if err != nil { @@ -193,5 +198,6 @@ func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error { if rows == 0 { return fmt.Errorf("deadline not found") } + s.audit.Log(ctx, "delete", "deadline", &deadlineID, nil, nil) return nil } diff --git a/backend/internal/services/document_service.go b/backend/internal/services/document_service.go index 3d60af8..9e7ef66 100644 --- a/backend/internal/services/document_service.go +++ b/backend/internal/services/document_service.go @@ -18,10 +18,11 @@ const documentBucket = "kanzlai-documents" type DocumentService struct { db *sqlx.DB storage *StorageClient + audit *AuditService } -func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService { - return &DocumentService{db: db, storage: storage} +func NewDocumentService(db *sqlx.DB, storage *StorageClient, audit *AuditService) *DocumentService { + return &DocumentService{db: db, storage: storage, audit: audit} } 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 { return nil, fmt.Errorf("fetching created document: %w", err) } + s.audit.Log(ctx, "create", "document", &id, nil, doc) return &doc, nil } @@ -151,6 +153,7 @@ func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uu // Log case event createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted", fmt.Sprintf("Document deleted: %s", doc.Title), nil) + s.audit.Log(ctx, "delete", "document", &docID, doc, nil) return nil } diff --git a/backend/internal/services/note_service.go b/backend/internal/services/note_service.go index d990ce4..b5f9385 100644 --- a/backend/internal/services/note_service.go +++ b/backend/internal/services/note_service.go @@ -13,11 +13,12 @@ import ( ) type NoteService struct { - db *sqlx.DB + db *sqlx.DB + audit *AuditService } -func NewNoteService(db *sqlx.DB) *NoteService { - return &NoteService{db: db} +func NewNoteService(db *sqlx.DB, audit *AuditService) *NoteService { + return &NoteService{db: db, audit: audit} } // 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 { return nil, fmt.Errorf("creating note: %w", err) } + s.audit.Log(ctx, "create", "note", &id, nil, n) 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) } + s.audit.Log(ctx, "update", "note", ¬eID, nil, n) return &n, nil } @@ -101,6 +104,7 @@ func (s *NoteService) Delete(ctx context.Context, tenantID, noteID uuid.UUID) er if rows == 0 { return fmt.Errorf("note not found") } + s.audit.Log(ctx, "delete", "note", ¬eID, nil, nil) return nil } diff --git a/backend/internal/services/party_service.go b/backend/internal/services/party_service.go index 8aeb3a3..a29b173 100644 --- a/backend/internal/services/party_service.go +++ b/backend/internal/services/party_service.go @@ -13,11 +13,12 @@ import ( ) type PartyService struct { - db *sqlx.DB + db *sqlx.DB + audit *AuditService } -func NewPartyService(db *sqlx.DB) *PartyService { - return &PartyService{db: db} +func NewPartyService(db *sqlx.DB, audit *AuditService) *PartyService { + return &PartyService{db: db, audit: audit} } 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 { return nil, fmt.Errorf("fetching created party: %w", err) } + s.audit.Log(ctx, "create", "party", &id, nil, party) 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 { return nil, fmt.Errorf("fetching updated party: %w", err) } + s.audit.Log(ctx, "update", "party", &partyID, current, updated) return &updated, nil } @@ -148,5 +151,6 @@ func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID) if rows == 0 { return sql.ErrNoRows } + s.audit.Log(ctx, "delete", "party", &partyID, nil, nil) return nil } diff --git a/backend/internal/services/tenant_service.go b/backend/internal/services/tenant_service.go index 7ed5614..dbc064d 100644 --- a/backend/internal/services/tenant_service.go +++ b/backend/internal/services/tenant_service.go @@ -13,11 +13,12 @@ import ( ) type TenantService struct { - db *sqlx.DB + db *sqlx.DB + audit *AuditService } -func NewTenantService(db *sqlx.DB) *TenantService { - return &TenantService{db: db} +func NewTenantService(db *sqlx.DB, audit *AuditService) *TenantService { + return &TenantService{db: db, audit: audit} } // 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) } + s.audit.Log(ctx, "create", "tenant", &tenant.ID, nil, tenant) 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) } + s.audit.Log(ctx, "create", "membership", &tenantID, nil, ut) return &ut, nil } @@ -186,6 +189,7 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID, if err != nil { return nil, fmt.Errorf("update settings: %w", err) } + s.audit.Log(ctx, "update", "settings", &tenantID, nil, settings) return &tenant, nil } @@ -223,5 +227,6 @@ func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid. 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 } diff --git a/frontend/src/app/(app)/cases/[id]/layout.tsx b/frontend/src/app/(app)/cases/[id]/layout.tsx index faafb59..a8ea73f 100644 --- a/frontend/src/app/(app)/cases/[id]/layout.tsx +++ b/frontend/src/app/(app)/cases/[id]/layout.tsx @@ -15,6 +15,7 @@ import { Users, StickyNote, AlertTriangle, + ScrollText, } from "lucide-react"; import { format } from "date-fns"; import { de } from "date-fns/locale"; @@ -44,6 +45,7 @@ const TABS = [ { segment: "dokumente", label: "Dokumente", icon: FileText }, { segment: "parteien", label: "Parteien", icon: Users }, { segment: "notizen", label: "Notizen", icon: StickyNote }, + { segment: "protokoll", label: "Protokoll", icon: ScrollText }, ] as const; const TAB_LABELS: Record = { @@ -52,6 +54,7 @@ const TAB_LABELS: Record = { dokumente: "Dokumente", parteien: "Parteien", notizen: "Notizen", + protokoll: "Protokoll", }; function CaseDetailSkeleton() { diff --git a/frontend/src/app/(app)/cases/[id]/protokoll/page.tsx b/frontend/src/app/(app)/cases/[id]/protokoll/page.tsx new file mode 100644 index 0000000..6f4b404 --- /dev/null +++ b/frontend/src/app/(app)/cases/[id]/protokoll/page.tsx @@ -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 = { + create: "Erstellt", + update: "Aktualisiert", + delete: "Geloescht", +}; + +const ACTION_COLORS: Record = { + 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 = { + case: "Akte", + deadline: "Frist", + appointment: "Termin", + document: "Dokument", + party: "Partei", + note: "Notiz", + settings: "Einstellungen", + membership: "Mitgliedschaft", +}; + +function DiffPreview({ + oldValues, + newValues, +}: { + oldValues?: Record; + newValues?: Record; +}) { + 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 ( +
+ {changes.slice(0, 5).map((c) => ( +
+ {c.key}: + {c.from !== undefined && ( + + {String(c.from)} + + )} + {c.to !== undefined && ( + + {String(c.to)} + + )} +
+ ))} + {changes.length > 5 && ( + + +{changes.length - 5} weitere Aenderungen + + )} +
+ ); +} + +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( + `/audit-log?entity_id=${id}&page=${page}&limit=50`, + ), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const entries = data?.entries ?? []; + const total = data?.total ?? 0; + const totalPages = Math.ceil(total / 50); + + if (entries.length === 0) { + return ( +
+ Keine Protokolleintraege vorhanden. +
+ ); + } + + return ( +
+
+ {entries.map((entry) => ( +
+
+
+ + {ACTION_LABELS[entry.action] ?? entry.action} + + + {ENTITY_LABELS[entry.entity_type] ?? entry.entity_type} + +
+ + {format(new Date(entry.created_at), "d. MMM yyyy, HH:mm", { + locale: de, + })} + +
+ +
+ ))} +
+ + {totalPages > 1 && ( +
+ + {total} Eintraege, Seite {page} von {totalPages} + +
+ {page > 1 && ( + + Zurueck + + )} + {page < totalPages && ( + + Weiter + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 458cc5b..d940c4f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -189,6 +189,27 @@ export interface Note { 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; + new_values?: Record; + ip_address?: string; + user_agent?: string; + created_at: string; +} + +export interface AuditLogResponse { + entries: AuditLogEntry[]; + total: number; + page: number; + limit: number; +} + export interface ApiError { error: string; status: number;