feat: Einladungslink für die Mitarbeiter-Selbstanmeldung

Schließt den ersten Punkt der geplanten Onboarding-Reihenfolge (siehe
CLAUDE.md): ein Admin sieht unter GET /verwaltung/einladung einen
Sammellink (account.einladung_token, Migration 0013), den beliebig
viele Mitarbeitende nutzen können, um sich selbst mit eigenem Passwort
anzumelden (GET/POST /einladung/{token}, öffentlich, Rolle
mitarbeiter). Kein Ablaufdatum, aber per Admin jederzeit erneuerbar —
das macht den alten Link sofort ungültig, falls er versehentlich
außerhalb des Unternehmens geteilt wurde. Keine E-Mail-Bestätigung
(bewusst konsistent mit dem Rest des Produkts, das noch keine
E-Mail-Infrastruktur hat).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-29 17:41:36 +02:00
parent eb99891e38
commit b91a58ccba
14 changed files with 504 additions and 27 deletions

View File

@@ -10,20 +10,35 @@ import (
)
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account.
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
type Account struct {
ID string
Name string
CreatedAt time.Time
ID string
Name string
EinladungToken string
CreatedAt time.Time
}
// CreateAccount legt einen neuen Mandanten an.
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
const accountColumns = `id, name, einladung_token, created_at`
func scanAccount(row interface {
Scan(dest ...any) error
}) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
return a, err
}
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
RETURNING id, name, created_at
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
RETURNING `+accountColumns,
name,
)
a, err := scanAccount(row)
if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err)
}
@@ -32,10 +47,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
// GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
SELECT id, name, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
@@ -45,12 +58,40 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
return a, nil
}
// GetAccountByEinladungToken liest den Mandanten zu einem
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
// oder bereits erneuertem (damit ungültig gewordenem) Token.
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
}
return a, nil
}
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
if err != nil {
return fmt.Errorf("store: regenerate einladung token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, created_at FROM account ORDER BY created_at DESC
`)
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
@@ -58,8 +99,8 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
var out []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
a, err := scanAccount(rows)
if err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)