- 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).
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
type PartyService struct {
|
|
db *sqlx.DB
|
|
audit *AuditService
|
|
}
|
|
|
|
func NewPartyService(db *sqlx.DB, audit *AuditService) *PartyService {
|
|
return &PartyService{db: db, audit: audit}
|
|
}
|
|
|
|
type CreatePartyInput struct {
|
|
Name string `json:"name"`
|
|
Role *string `json:"role,omitempty"`
|
|
Representative *string `json:"representative,omitempty"`
|
|
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
|
}
|
|
|
|
type UpdatePartyInput struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Role *string `json:"role,omitempty"`
|
|
Representative *string `json:"representative,omitempty"`
|
|
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
|
}
|
|
|
|
func (s *PartyService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.Party, error) {
|
|
var parties []models.Party
|
|
err := s.db.SelectContext(ctx, &parties,
|
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
|
|
caseID, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing parties: %w", err)
|
|
}
|
|
return parties, nil
|
|
}
|
|
|
|
func (s *PartyService) Create(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input CreatePartyInput) (*models.Party, error) {
|
|
// Verify case exists and belongs to tenant
|
|
var exists bool
|
|
err := s.db.GetContext(ctx, &exists,
|
|
"SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)", caseID, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checking case: %w", err)
|
|
}
|
|
if !exists {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
id := uuid.New()
|
|
contactInfo := input.ContactInfo
|
|
if contactInfo == nil {
|
|
contactInfo = json.RawMessage("{}")
|
|
}
|
|
|
|
_, err = s.db.ExecContext(ctx,
|
|
`INSERT INTO parties (id, tenant_id, case_id, name, role, representative, contact_info)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
id, tenantID, caseID, input.Name, input.Role, input.Representative, contactInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating party: %w", err)
|
|
}
|
|
|
|
// Log event
|
|
desc := fmt.Sprintf("Party added: %s", input.Name)
|
|
createEvent(ctx, s.db, tenantID, caseID, userID, "party_added", desc, nil)
|
|
|
|
var party models.Party
|
|
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
|
|
}
|
|
|
|
func (s *PartyService) Update(ctx context.Context, tenantID, partyID uuid.UUID, input UpdatePartyInput) (*models.Party, error) {
|
|
// Verify party exists and belongs to tenant
|
|
var current models.Party
|
|
err := s.db.GetContext(ctx, ¤t,
|
|
"SELECT * FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("fetching party: %w", err)
|
|
}
|
|
|
|
sets := []string{}
|
|
args := []interface{}{}
|
|
argIdx := 1
|
|
|
|
if input.Name != nil {
|
|
sets = append(sets, fmt.Sprintf("name = $%d", argIdx))
|
|
args = append(args, *input.Name)
|
|
argIdx++
|
|
}
|
|
if input.Role != nil {
|
|
sets = append(sets, fmt.Sprintf("role = $%d", argIdx))
|
|
args = append(args, *input.Role)
|
|
argIdx++
|
|
}
|
|
if input.Representative != nil {
|
|
sets = append(sets, fmt.Sprintf("representative = $%d", argIdx))
|
|
args = append(args, *input.Representative)
|
|
argIdx++
|
|
}
|
|
if input.ContactInfo != nil {
|
|
sets = append(sets, fmt.Sprintf("contact_info = $%d", argIdx))
|
|
args = append(args, input.ContactInfo)
|
|
argIdx++
|
|
}
|
|
|
|
if len(sets) == 0 {
|
|
return ¤t, nil
|
|
}
|
|
|
|
query := fmt.Sprintf("UPDATE parties SET %s WHERE id = $%d AND tenant_id = $%d",
|
|
joinStrings(sets, ", "), argIdx, argIdx+1)
|
|
args = append(args, partyID, tenantID)
|
|
|
|
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
|
return nil, fmt.Errorf("updating party: %w", err)
|
|
}
|
|
|
|
var updated models.Party
|
|
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
|
|
}
|
|
|
|
func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID) error {
|
|
result, err := s.db.ExecContext(ctx,
|
|
"DELETE FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting party: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
s.audit.Log(ctx, "delete", "party", &partyID, nil, nil)
|
|
return nil
|
|
}
|