- 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).
125 lines
3.8 KiB
Go
125 lines
3.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
|
)
|
|
|
|
type NoteService struct {
|
|
db *sqlx.DB
|
|
audit *AuditService
|
|
}
|
|
|
|
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.
|
|
func (s *NoteService) ListByParent(ctx context.Context, tenantID uuid.UUID, parentType string, parentID uuid.UUID) ([]models.Note, error) {
|
|
col, err := parentColumn(parentType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query := fmt.Sprintf(
|
|
`SELECT id, tenant_id, case_id, deadline_id, appointment_id, case_event_id,
|
|
content, created_by, created_at, updated_at
|
|
FROM notes
|
|
WHERE tenant_id = $1 AND %s = $2
|
|
ORDER BY created_at DESC`, col)
|
|
|
|
var notes []models.Note
|
|
if err := s.db.SelectContext(ctx, ¬es, query, tenantID, parentID); err != nil {
|
|
return nil, fmt.Errorf("listing notes by %s: %w", parentType, err)
|
|
}
|
|
if notes == nil {
|
|
notes = []models.Note{}
|
|
}
|
|
return notes, nil
|
|
}
|
|
|
|
type CreateNoteInput struct {
|
|
CaseID *uuid.UUID `json:"case_id,omitempty"`
|
|
DeadlineID *uuid.UUID `json:"deadline_id,omitempty"`
|
|
AppointmentID *uuid.UUID `json:"appointment_id,omitempty"`
|
|
CaseEventID *uuid.UUID `json:"case_event_id,omitempty"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// Create inserts a new note.
|
|
func (s *NoteService) Create(ctx context.Context, tenantID uuid.UUID, createdBy *uuid.UUID, input CreateNoteInput) (*models.Note, error) {
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
query := `INSERT INTO notes (id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
|
RETURNING id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at`
|
|
|
|
var n models.Note
|
|
err := s.db.GetContext(ctx, &n, query,
|
|
id, tenantID, input.CaseID, input.DeadlineID, input.AppointmentID, input.CaseEventID,
|
|
input.Content, createdBy, now)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating note: %w", err)
|
|
}
|
|
s.audit.Log(ctx, "create", "note", &id, nil, n)
|
|
return &n, nil
|
|
}
|
|
|
|
// Update modifies a note's content.
|
|
func (s *NoteService) Update(ctx context.Context, tenantID, noteID uuid.UUID, content string) (*models.Note, error) {
|
|
query := `UPDATE notes SET content = $1, updated_at = $2
|
|
WHERE id = $3 AND tenant_id = $4
|
|
RETURNING id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at`
|
|
|
|
var n models.Note
|
|
err := s.db.GetContext(ctx, &n, query, content, time.Now().UTC(), noteID, tenantID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("updating note: %w", err)
|
|
}
|
|
s.audit.Log(ctx, "update", "note", ¬eID, nil, n)
|
|
return &n, nil
|
|
}
|
|
|
|
// Delete removes a note.
|
|
func (s *NoteService) Delete(ctx context.Context, tenantID, noteID uuid.UUID) error {
|
|
result, err := s.db.ExecContext(ctx, "DELETE FROM notes WHERE id = $1 AND tenant_id = $2", noteID, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting note: %w", err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("checking delete result: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("note not found")
|
|
}
|
|
s.audit.Log(ctx, "delete", "note", ¬eID, nil, nil)
|
|
return nil
|
|
}
|
|
|
|
func parentColumn(parentType string) (string, error) {
|
|
switch parentType {
|
|
case "case":
|
|
return "case_id", nil
|
|
case "deadline":
|
|
return "deadline_id", nil
|
|
case "appointment":
|
|
return "appointment_id", nil
|
|
case "case_event":
|
|
return "case_event_id", nil
|
|
default:
|
|
return "", fmt.Errorf("invalid parent type: %s", parentType)
|
|
}
|
|
}
|