- Database: kanzlai.audit_log table with RLS, append-only policies (no UPDATE/DELETE), indexes for entity, user, and time queries - Backend: AuditService.Log() with context-based tenant/user/IP/UA extraction, wired into all 7 services (case, deadline, appointment, document, note, party, tenant) - API: GET /api/audit-log with entity_type, entity_id, user_id, from/to date, and pagination filters - Frontend: Protokoll tab on case detail page with chronological audit entries, diff preview, and pagination Required by § 50 BRAO and DSGVO Art. 5(2).
286 lines
8.2 KiB
Go
286 lines
8.2 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
type CaseService struct {
|
|
db *sqlx.DB
|
|
audit *AuditService
|
|
}
|
|
|
|
func NewCaseService(db *sqlx.DB, audit *AuditService) *CaseService {
|
|
return &CaseService{db: db, audit: audit}
|
|
}
|
|
|
|
type CaseFilter struct {
|
|
Status string
|
|
Type string
|
|
Search string
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
type CaseDetail struct {
|
|
models.Case
|
|
Parties []models.Party `json:"parties"`
|
|
RecentEvents []models.CaseEvent `json:"recent_events"`
|
|
DeadlinesCount int `json:"deadlines_count"`
|
|
}
|
|
|
|
type CreateCaseInput struct {
|
|
CaseNumber string `json:"case_number"`
|
|
Title string `json:"title"`
|
|
CaseType *string `json:"case_type,omitempty"`
|
|
Court *string `json:"court,omitempty"`
|
|
CourtRef *string `json:"court_ref,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type UpdateCaseInput struct {
|
|
CaseNumber *string `json:"case_number,omitempty"`
|
|
Title *string `json:"title,omitempty"`
|
|
CaseType *string `json:"case_type,omitempty"`
|
|
Court *string `json:"court,omitempty"`
|
|
CourtRef *string `json:"court_ref,omitempty"`
|
|
Status *string `json:"status,omitempty"`
|
|
}
|
|
|
|
func (s *CaseService) List(ctx context.Context, tenantID uuid.UUID, filter CaseFilter) ([]models.Case, int, error) {
|
|
if filter.Limit <= 0 {
|
|
filter.Limit = 20
|
|
}
|
|
if filter.Limit > 100 {
|
|
filter.Limit = 100
|
|
}
|
|
|
|
// Build WHERE clause
|
|
where := "WHERE tenant_id = $1"
|
|
args := []interface{}{tenantID}
|
|
argIdx := 2
|
|
|
|
if filter.Status != "" {
|
|
where += fmt.Sprintf(" AND status = $%d", argIdx)
|
|
args = append(args, filter.Status)
|
|
argIdx++
|
|
}
|
|
if filter.Type != "" {
|
|
where += fmt.Sprintf(" AND case_type = $%d", argIdx)
|
|
args = append(args, filter.Type)
|
|
argIdx++
|
|
}
|
|
if filter.Search != "" {
|
|
where += fmt.Sprintf(" AND (title ILIKE $%d OR case_number ILIKE $%d)", argIdx, argIdx)
|
|
args = append(args, "%"+filter.Search+"%")
|
|
argIdx++
|
|
}
|
|
|
|
// Count total
|
|
var total int
|
|
countQuery := "SELECT COUNT(*) FROM cases " + where
|
|
if err := s.db.GetContext(ctx, &total, countQuery, args...); err != nil {
|
|
return nil, 0, fmt.Errorf("counting cases: %w", err)
|
|
}
|
|
|
|
// Fetch page
|
|
query := fmt.Sprintf("SELECT * FROM cases %s ORDER BY updated_at DESC LIMIT $%d OFFSET $%d",
|
|
where, argIdx, argIdx+1)
|
|
args = append(args, filter.Limit, filter.Offset)
|
|
|
|
var cases []models.Case
|
|
if err := s.db.SelectContext(ctx, &cases, query, args...); err != nil {
|
|
return nil, 0, fmt.Errorf("listing cases: %w", err)
|
|
}
|
|
|
|
return cases, total, nil
|
|
}
|
|
|
|
func (s *CaseService) GetByID(ctx context.Context, tenantID, caseID uuid.UUID) (*CaseDetail, error) {
|
|
var c models.Case
|
|
err := s.db.GetContext(ctx, &c,
|
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("getting case: %w", err)
|
|
}
|
|
|
|
detail := &CaseDetail{Case: c}
|
|
|
|
// Parties
|
|
if err := s.db.SelectContext(ctx, &detail.Parties,
|
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
|
|
caseID, tenantID); err != nil {
|
|
return nil, fmt.Errorf("getting parties: %w", err)
|
|
}
|
|
|
|
// Recent events (last 20)
|
|
if err := s.db.SelectContext(ctx, &detail.RecentEvents,
|
|
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 20",
|
|
caseID, tenantID); err != nil {
|
|
return nil, fmt.Errorf("getting events: %w", err)
|
|
}
|
|
|
|
// Deadlines count
|
|
if err := s.db.GetContext(ctx, &detail.DeadlinesCount,
|
|
"SELECT COUNT(*) FROM deadlines WHERE case_id = $1 AND tenant_id = $2",
|
|
caseID, tenantID); err != nil {
|
|
return nil, fmt.Errorf("counting deadlines: %w", err)
|
|
}
|
|
|
|
return detail, nil
|
|
}
|
|
|
|
func (s *CaseService) Create(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, input CreateCaseInput) (*models.Case, error) {
|
|
if input.Status == "" {
|
|
input.Status = "active"
|
|
}
|
|
|
|
id := uuid.New()
|
|
now := time.Now()
|
|
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status, metadata, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $9, $9)`,
|
|
id, tenantID, input.CaseNumber, input.Title, input.CaseType, input.Court, input.CourtRef, input.Status, now)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating case: %w", err)
|
|
}
|
|
|
|
// Create case_created event
|
|
createEvent(ctx, s.db, tenantID, id, userID, "case_created", "Case created", nil)
|
|
|
|
var c models.Case
|
|
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
|
|
}
|
|
|
|
func (s *CaseService) Update(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input UpdateCaseInput) (*models.Case, error) {
|
|
// Fetch current to detect status change
|
|
var current models.Case
|
|
err := s.db.GetContext(ctx, ¤t,
|
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("fetching case for update: %w", err)
|
|
}
|
|
|
|
// Build SET clause dynamically
|
|
sets := []string{}
|
|
args := []interface{}{}
|
|
argIdx := 1
|
|
|
|
if input.CaseNumber != nil {
|
|
sets = append(sets, fmt.Sprintf("case_number = $%d", argIdx))
|
|
args = append(args, *input.CaseNumber)
|
|
argIdx++
|
|
}
|
|
if input.Title != nil {
|
|
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
|
|
args = append(args, *input.Title)
|
|
argIdx++
|
|
}
|
|
if input.CaseType != nil {
|
|
sets = append(sets, fmt.Sprintf("case_type = $%d", argIdx))
|
|
args = append(args, *input.CaseType)
|
|
argIdx++
|
|
}
|
|
if input.Court != nil {
|
|
sets = append(sets, fmt.Sprintf("court = $%d", argIdx))
|
|
args = append(args, *input.Court)
|
|
argIdx++
|
|
}
|
|
if input.CourtRef != nil {
|
|
sets = append(sets, fmt.Sprintf("court_ref = $%d", argIdx))
|
|
args = append(args, *input.CourtRef)
|
|
argIdx++
|
|
}
|
|
if input.Status != nil {
|
|
sets = append(sets, fmt.Sprintf("status = $%d", argIdx))
|
|
args = append(args, *input.Status)
|
|
argIdx++
|
|
}
|
|
|
|
if len(sets) == 0 {
|
|
return ¤t, nil
|
|
}
|
|
|
|
sets = append(sets, fmt.Sprintf("updated_at = $%d", argIdx))
|
|
args = append(args, time.Now())
|
|
argIdx++
|
|
|
|
query := fmt.Sprintf("UPDATE cases SET %s WHERE id = $%d AND tenant_id = $%d",
|
|
joinStrings(sets, ", "), argIdx, argIdx+1)
|
|
args = append(args, caseID, tenantID)
|
|
|
|
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
|
return nil, fmt.Errorf("updating case: %w", err)
|
|
}
|
|
|
|
// Log status change event
|
|
if input.Status != nil && *input.Status != current.Status {
|
|
desc := fmt.Sprintf("Status changed from %s to %s", current.Status, *input.Status)
|
|
createEvent(ctx, s.db, tenantID, caseID, userID, "status_changed", desc, nil)
|
|
}
|
|
|
|
var updated models.Case
|
|
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
|
|
}
|
|
|
|
func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID) error {
|
|
result, err := s.db.ExecContext(ctx,
|
|
"UPDATE cases SET status = 'archived', updated_at = $1 WHERE id = $2 AND tenant_id = $3 AND status != 'archived'",
|
|
time.Now(), caseID, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("archiving case: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
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
|
|
}
|
|
|
|
func createEvent(ctx context.Context, db *sqlx.DB, tenantID, caseID uuid.UUID, userID uuid.UUID, eventType, title string, description *string) {
|
|
now := time.Now()
|
|
db.ExecContext(ctx,
|
|
`INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $7, $7)`,
|
|
uuid.New(), tenantID, caseID, eventType, title, description, now, userID)
|
|
}
|
|
|
|
func joinStrings(strs []string, sep string) string {
|
|
result := ""
|
|
for i, s := range strs {
|
|
if i > 0 {
|
|
result += sep
|
|
}
|
|
result += s
|
|
}
|
|
return result
|
|
}
|