feat: Ebene 4 vollständig steuerbar machen — Abteilungen, Werkzeug-Sperrungen, Nutzer-Deaktivierung
Deklarix soll ein buchbarer Service werden — dafür muss jede Entität im
Datenmodell über das Frontend steuerbar sein, nicht nur einsehbar.
Schließt drei konkrete Lücken:
- Abteilungen (GET /verwaltung/abteilungen, anlegen/löschen) — ohne
diese Seite blieb die Abteilung-Auswahl im Antrag-Fragebogen leer
und unbenutzbar, das war ein Funktionsdefizit, kein Komfortfehler.
- Eigene Werkzeug-Sperrungen (GET /verwaltung/werkzeuge) — ein Mandant
kann einen zentralen Katalogeintrag jetzt für sich sperren/entsperren,
ohne den zentralen Katalog selbst zu verändern.
- Nutzer-Deaktivierung (POST /verwaltung/nutzer/{id}/deaktivieren bzw.
.../aktivieren, neue Spalte app_user.active, Migration 0012). Nutzer
werden nicht gelöscht (Fremdschlüssel auf antrag/entscheidung/
audit_log würden das verhindern und die Historie zerstören) —
deaktivierte Logins können sich nicht mehr anmelden und verlieren
eine laufende Sitzung sofort. Ein Admin kann sich nicht selbst
deaktivieren.
Zusätzlich: store.ListAktiveGenehmigungenForAccount als Grundlage für
die Wiedervorlage (Schritt 7, Web-Layer folgt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,49 @@ func TestUserCRUD(t *testing.T) {
|
||||
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||
}
|
||||
if !byID.Active {
|
||||
t.Error("expected a newly created user to be Active by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActive(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
|
||||
t.Fatalf("SetUserActive (false): %v", err)
|
||||
}
|
||||
deaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if deaktiviert.Active {
|
||||
t.Fatal("expected the user to be inactive")
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
|
||||
t.Fatalf("SetUserActive (true): %v", err)
|
||||
}
|
||||
reaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if !reaktiviert.Active {
|
||||
t.Fatal("expected the user to be active again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActiveNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||
|
||||
@@ -109,3 +109,38 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen
|
||||
// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage
|
||||
// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende
|
||||
// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit
|
||||
// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche
|
||||
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
||||
// zum Mandanten läuft über den zugehörigen antrag.
|
||||
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
||||
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||
FROM entscheidung e
|
||||
JOIN antrag a ON a.id = e.antrag_id
|
||||
WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen')
|
||||
ORDER BY e.created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Entscheidung
|
||||
for rows.Next() {
|
||||
e, err := scanEntscheidung(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan entscheidung: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -113,3 +113,41 @@ func TestEntscheidungIsAppendOnly(t *testing.T) {
|
||||
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAktiveGenehmigungenForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
antragGenehmigt, b1 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragGenehmigt.ID, BewertungID: b1.ID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (genehmigt): %v", err)
|
||||
}
|
||||
|
||||
antragAbgelehnt, b2 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragAbgelehnt.ID, BewertungID: b2.ID, EntscheiderUserID: userID, Entscheidung: "abgelehnt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (abgelehnt): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListAktiveGenehmigungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].AntragID != antragGenehmigt.ID {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount = %+v, want exactly the genehmigt entscheidung", list)
|
||||
}
|
||||
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListAktiveGenehmigungenForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount (other): %v", err)
|
||||
}
|
||||
if len(otherList) != 0 {
|
||||
t.Fatalf("expected no genehmigungen for a different tenant, got %+v", otherList)
|
||||
}
|
||||
}
|
||||
|
||||
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_user DROP COLUMN active;
|
||||
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Nutzer können nicht gelöscht werden (app_user wird von antrag,
|
||||
-- session, bewertung [über antrag], entscheidung, audit_log,
|
||||
-- registereintrag [über antrag] per Foreign Key referenziert — ein
|
||||
-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren.
|
||||
-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber
|
||||
-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar.
|
||||
ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -15,28 +15,41 @@ import (
|
||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||
|
||||
// User ist ein Login innerhalb eines Account (Mandanten).
|
||||
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
|
||||
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
|
||||
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
|
||||
// Historie zerstören), sondern über Active deaktiviert.
|
||||
type User struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
|
||||
|
||||
func scanUser(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||
// speichert nur, es hasht nicht selbst.
|
||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, email, password_hash, role, created_at
|
||||
`, accountID, email, passwordHash, role).Scan(
|
||||
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
||||
RETURNING `+userColumns,
|
||||
accountID, email, passwordHash, role,
|
||||
)
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||
}
|
||||
@@ -46,11 +59,8 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
|
||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE email = $1
|
||||
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -62,11 +72,8 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||
|
||||
// GetUser liest einen Nutzer anhand seiner ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE id = $1
|
||||
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -80,8 +87,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
// Admin-Bereich (Account-Detailansicht).
|
||||
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
@@ -90,8 +96,8 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
||||
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
@@ -101,3 +107,18 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set user active: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user