Product Requirements Document · solcom · P2503-106 · F-280 · superseded

Personio-REST-Import: authentifizierte Spiegelung der Stellenanzeigen · superseded

Verworfen am 2026-05-15. Der hier skizzierte authentifizierte REST-Refactor wurde nach Live-Spike verworfen: REST liefert nur 8 Header-Felder, alle Content-Felder leben ausschließlich im öffentlichen XML-Feed. Kanonischer Sync-Pfad bleibt Migrate+XML (siehe Story F-280-personio-xml-feed.html). Architektur-Begründung: ADR-0035. Inhalt unten bleibt als historisches Spike-Artefakt erhalten.
TL;DR — Drupal spiegelt SOLCOM-Stellenanzeigen aus Personio über die authentifizierte Personio REST API v1 in den Bundle node/vacancy. Auth via JWT-Bearer (Client-ID/Secret aus dem drupal/key-Modul mit Env-Provider). Sync läuft stündlich via hook_cron: synchroner Hard-Delete-Sweep, danach Queue-Item pro offene Stelle, @QueueWorker arbeitet Insert/Update ab. Datenmodell, Vocabularies und Canvas-Templates bleiben unverändert. Scope: Inbound-Sync der offenen Stellen + Compliance-konformer Auth-Layer + operative Tooling. Out of Scope: Bidirektionaler Sync, Bewerber-Daten, Real-time-Webhooks. Status: draft · Erstellt: 2026-05-15 · Story: P2503-106 (F-280) · MVP-Termin: 2026-07-08

Problem Statement

SOLCOM pflegt offene Stellenanzeigen vollständig in Personio (HR-Tooling). Die SOLCOM-Karriere-Section auf solcom.de spiegelt diese Stellen automatisiert in den Drupal-Bundle node/vacancy. Compliance- und IT-Security-seitige Anforderungen verlangen einen authentifizierten Zugriff auf Personio-Daten — anonyme oder öffentliche Datenquellen sind nicht mehr zulässig.

Stimme aus Compliance / IT-Security

„Externe Datenquellen werden authentifiziert angesprochen, mit nachvollziehbarem Audit-Trail. Credentials liegen nicht im Repository, Rotation muss möglich sein, und der Zugriff ist auf eine dedizierte Service-Identity gebunden. Personio liefert dafür eine REST-API mit Client-ID/Client-Secret-Authentication und JWT-Bearer-Tokens."

Solution

Das Modul solcom_vacancy erhält eine REST-API-basierte Sync-Architektur in drei Service-Schichten, getriggert von Drupal-Core-Cron und ausgeführt über die Drupal Queue-API. Bundle, Felder, Vocabularies und Canvas-Templates bleiben unverändert — der Sync ist transparent für die Anzeige-Schicht.

SOLCOM-HR pflegt Stellen in Personio. Spätestens nach 60 Minuten ist eine neue Stelle auf solcom.de sichtbar. Beim Schließen einer Stelle in Personio verschwindet sie nach maximal einer Stunde aus der Drupal-Site. Authentifizierung läuft via Service-Identity, Credentials liegen nicht im Repository.

Goals (V1)

  1. Service PersonioApiClient authentifiziert sich gegen Personio REST API v1, holt einen JWT-Bearer-Token, cached ihn mit TTL und erneuert bei 401 einmalig.
  2. Credentials liegen nicht im Repo: Client-ID und Client-Secret werden als Key-Entities im drupal/key-Modul angelegt, Storage über den Env-Provider (Env-Vars PERSONIO_CLIENT_ID + PERSONIO_CLIENT_SECRET).
  3. Service PersonioPositionMapper übersetzt eine Personio-Position-Response in das bestehende Vacancy-Field-Set; Term-Auto-Create-Logik für Picklist-Drift bleibt erhalten und loggt jeden Auto-Create als Watchdog-Warning.
  4. Service PersonioVacancySyncer orchestriert Pull → synchroner Sweep → Queue-Befüllung; @QueueWorker arbeitet Items asynchron ab. hook_cron triggert stündlich (State-gated, identisch zur bisherigen Cadence).
  5. Hard-Delete-Lifecycle: In Personio entfernte Stellen verschwinden aus Drupal nach maximal einer Cron-Periode. Empty-Response-Schutz: Bei 0 zurückgegebenen Positions wird der Sweep übersprungen und eine Watchdog-Warning geloggt, bestehende Nodes bleiben.
  6. Drush-Command solcom_vacancy:sync erlaubt Manual-Run mit Status-Output (Anzahl synced/skipped/deleted, JWT-Token-Cache-State, letzter Cron-Run); ersetzt operativ den entfallenden drush migrate:status-Workflow.
  7. Watchdog-Channel solcom_vacancy loggt jeden Pull-Run (Anzahl Positions, Sweep-Aktionen, HTTP-Fehler, Token-Refreshes, Term-Auto-Creates).
  8. Deployment-Sequence: Der atomare Merge von Sub-Task 4+5 (siehe Sub-Task-Plan) ersetzt den bestehenden Cron-Pfad durch den neuen — kein Zeitfenster mit konkurrierenden Sync-Mechanismen.
  9. ADR-0031 (key-Modul-Pattern für externe API-Credentials) und ADR-0032 (Service+Queue-Pattern für authentifizierte externe REST-Syncs ohne Migrate-API) dokumentieren die Pattern-Entscheidungen; ADR-0029 wird in-place auf das key-Modul-Pattern aktualisiert.
  10. Tests: PersonioPositionMapper (Unit, ≥6 Test-Cases), PersonioApiClient + PersonioVacancySyncer + @QueueWorker (Kernel mit Mock-HTTP), JSON-LD-Rendering (Functional, unverändert vom Bestand).

User Stories

  1. Als HR-Verantwortliche:r möchte ich eine Stelle in Personio anlegen, ändern oder schließen, damit sie spätestens nach 60 Minuten auf solcom.de erscheint, aktualisiert wird oder verschwindet — ohne dass irgendwer manuell in Drupal etwas pflegt.
  2. Als Stellensuchende:r möchte ich offene SOLCOM-Stellen im Karriere-Bereich von solcom.de browsen, damit ich tagesaktuell den Bestand sehe.
  3. Als Stellensuchende:r möchte ich pro Stelle Office, Department, Schedule, Seniority und Gehalts-Range als deutsche Strings sehen, damit ich ohne HR-Vokabular sofort einordnen kann, worum es geht.
  4. Als Drupal-Editor:in möchte ich Vacancy-Nodes nicht manuell editieren oder anlegen können — Personio ist Source-of-Truth, jede Drupal-seitige Änderung würde beim nächsten Sync überschrieben (konsistent mit ADR-0026 Vacancy-Editorial-Ausschluss).
  5. Als System-Operator:in möchte ich, dass Personio-API-Credentials nicht im Git-Repository liegen, damit der Sicherheits-Standard eingehalten wird und Rotation möglich ist.
  6. Als System-Operator:in möchte ich, dass der Sweep nicht den gesamten Vacancy-Bestand löscht, wenn die Personio-API einen leeren Response liefert, damit Personio-Outages nicht zu Daten-Verlust führen.
  7. Als System-Operator:in möchte ich einen Drush-Command für Manual-Run und einen Status-Output, damit ich ohne Browser-Login den Sync-Zustand bewerten kann.
  8. Als Compliance-Verantwortliche:r möchte ich, dass jeder Personio-Zugriff authentifiziert läuft (Service-Identity, kein anonymes Lesen), damit der Audit-Trail vollständig ist.
  9. Als Developer:in möchte ich, dass auto-erzeugte Taxonomy-Terms (bei Personio-Picklist-Drift, z. B. ein neues Office oder Department) mit einer Watchdog-Warning auffallen, damit Editor:innen die deutschen Labels proaktiv nachpflegen können.
  10. Als Developer:in möchte ich, dass alle Personio-Response-Annahmen vor der ersten Mapper-Zeile gegen die echte API verifiziert werden (Spike-Pre-Condition), damit Field-Name-Drifts nicht erst im Kernel-Test auffallen.
  11. Als Developer:in möchte ich, dass das Pattern für authentifizierte externe REST-Syncs (Auth via key-Modul, Service+Queue ohne Migrate-API) explizit als ADR dokumentiert ist, damit künftige Integrationen (Salesforce-Publication-Sync, weitere) konsistent gebaut werden.

Implementation Decisions

Sync-Stack — Service + hook_cron + Queue-API (kein Migrate-API)

Der Sync wird als Service-Layer (PersonioApiClient + PersonioPositionMapper + PersonioVacancySyncer) implementiert, der von hook_cron getriggert wird. Idempotenz läuft über das bestehende Feld field_personio_id (Drupal-EntityQuery-Lookup vor Insert/Update). Hard-Delete-Sweep läuft synchron im hook_cron, bevor Queue-Items angelegt werden. Asynchrone Insert/Update-Verarbeitung via @QueueWorker.

Rejected: Migrate-API mit migrate_plus + Custom-Authentication-Plugin — Migrate-APIs Source-Plugin-Ökosystem ist auf öffentliche oder einfach-authentifizierte Feeds optimiert; ein JWT-Bearer-Flow mit Token-Cache und Refresh-on-401 zwingt ohnehin in Custom-Code (Custom Fetcher oder Custom Authentication-Plugin). Damit verliert die Migrate-API ihren Mehrwert (ID-Map, Process-Pipeline) gegenüber einem eigenen Service-Layer, behält aber den Maintenance-Surface. Service+Queue ist ehrlicher und kleiner. Konsequenz: migrate_plus und migrate_tools entfallen als Modul-Dependencies. Begründung als ADR-0032 dokumentiert. Konsequenz für CONTEXT.md: Glossar-Eintrag „Vacancy — pulled via drupal:migrate" wird auf das neue Pattern angepasst.

Rejected: Komplett-Eigenbau ohne Drupal-Queue-API (synchrone Verarbeitung im hook_cron) — funktioniert technisch bei < 100 Stellen, hat aber keinen Headroom für Wachstum oder Personio-Latenz und verzichtet auf Retry-Backoff bei transienten Fehlern. Queue-API addet wenig Code (~30 LoC für QueueWorker-Annotation und Worker-Body), bringt Drupal-native Retry-Mechanik mit.

API-Client + Auth-Layer

Service PersonioApiClient in src/Service/PersonioApiClient.php. Verantwortlich für:

Token-Service ist bewusst lokal im Modul solcom_vacancy — kein generisches solcom_integrations-Modul. Falls SF-Publication beim Bau dasselbe Pattern adoptiert, ist die Extraktion in einen Shared-Layer ein additives Refactor — Karpathy-Prinzip: nicht für hypothetische Zukunft designen.

Credential-Storage — drupal/key-Modul mit Env-Provider

Modul drupal/key wird als Composer-Dependency hinzugefügt und im Default-Sync aktiviert. Zwei Key-Entities (personio_client_id, personio_client_secret) liegen als key.key.*.yml in config/sync/ — die Config-Files enthalten nur die Provider-Konfiguration, nicht die Werte. Provider: env (key-Modul Built-in). Reale Werte werden via Env-Vars PERSONIO_CLIENT_ID und PERSONIO_CLIENT_SECRET in settings.local.php (lokal) und Mittwald-App-Config (Stage/Prod) bereitgestellt.

ADR-0029 wird in-place aktualisiert: das dort definierte SF-Pattern (direktes getenv() im hook_migration_plugins_alter) wird durch das key-Modul-Pattern ersetzt. SF-Publication adoptiert das key-Modul-Pattern beim Bau (P2503-108). Begründung als ADR-0031 dokumentiert.

Rejected: Direktes getenv() in Service-Code (kein Audit-Trail in Drupal, keine UI für Rotation, keine Provider-Abstraktion); drupal/encrypt mit Drupal-DB-Schlüssel (Schlüsselverwaltung wird selbst-referenziell); File-Provider von drupal/key mit auf-dem-Server-File (kein operativer Vorteil gegenüber Env-Var, dafür zusätzlicher File-Permission-Surface).

Position-Mapper — JSON zu Drupal-Field-Werten

Service PersonioPositionMapper in src/Service/PersonioPositionMapper.php. Methode map(array $position): array liefert das Drupal-Field-Array für den Bundle vacancy. Verantwortlich für:

Cron + Queue + Sweep

hook_cron in solcom_vacancy.module ruft PersonioVacancySyncer::syncFromCron(). State-Gating bleibt erhalten (kein zweiter Run innerhalb von 3600s). Lifecycle pro Run:

  1. Pull: GET https://api.personio.de/v1/recruiting/positions liefert die Liste der offenen Stellen.
  2. Sweep (synchron, race-frei): Drupal-EntityQuery: Vacancy-Nodes mit field_personio_id NOT IN $current_personio_ids werden direkt via EntityStorage::delete() entfernt. Empty-Response-Schutz: Bei 0 Personio-IDs wird der Sweep übersprungen und eine Watchdog-Warning geloggt — bestehende Nodes bleiben.
  3. Enqueue: Für jede in der Response enthaltene Position wird ein Queue-Item {personio_id: "<id>", payload: <array>} in queue.factory.get('personio_position_import') gelegt.
  4. Verarbeitung: @QueueWorker('personio_position_import') arbeitet jedes Item ab — Lookup-or-Create Drupal-Node anhand field_personio_id, Mapping via PersonioPositionMapper, save.

Die Annotation cron => ['time' => 60] auf dem QueueWorker erlaubt dem Drupal-Cron pro Cron-Run maximal 60 Sekunden auf der Queue zu arbeiten — ausreichend für SOLCOMs Stellen-Bestand (geschätzt 50-100 Stellen) mit Headroom für Personio-Latenz und Retry-Backoff bei transienten 5xx-Fehlern.

Operative Sichtbarkeit

Da Migrate-Tools entfällt, brauchen wir expliziten Tooling-Ersatz:

Modul-Layout solcom_vacancy (Soll-Zustand)

web/modules/custom/solcom_vacancy/
├── solcom_vacancy.info.yml          # dependencies: drupal/key, drupal/node, drupal/taxonomy, ... (kein migrate_plus, kein migrate_tools)
├── solcom_vacancy.install
├── solcom_vacancy.module            # hook_cron → PersonioVacancySyncer; hook_node_view → JSON-LD (unverändert)
├── solcom_vacancy.permissions.yml
├── solcom_vacancy.services.yml      # PersonioApiClient, PersonioPositionMapper,
│                                    # PersonioVacancySyncer, VacancyJsonLdBuilder,
│                                    # logger.channel.solcom_vacancy
├── src/
│   ├── Service/
│   │   ├── PersonioApiClient.php
│   │   ├── PersonioPositionMapper.php
│   │   ├── PersonioVacancySyncer.php
│   │   └── VacancyJsonLdBuilder.php           # unverändert
│   ├── Plugin/
│   │   └── QueueWorker/
│   │       └── PersonioPositionWorker.php
│   ├── Commands/
│   │   └── SolcomVacancyCommands.php          # drush commands: sync, status
│   └── SalaryDisplayComputedItemList.php       # unverändert
└── tests/src/
    ├── Unit/Service/
    │   └── PersonioPositionMapperTest.php
    └── Kernel/
        ├── Service/
        │   ├── PersonioApiClientTest.php
        │   └── PersonioVacancySyncerTest.php
        └── Plugin/QueueWorker/
            └── PersonioPositionWorkerTest.php

Zusätzlich in config/sync/: key.key.personio_client_id.yml, key.key.personio_client_secret.yml, Update von core.extension.yml (key aktiviert, migrate_plus + migrate_tools entfernt). Die bestehenden Migrate-YAMLs migrate_plus.migration.solcom_vacancy.yml und migrate_plus.migration_group.solcom_vacancy.yml entfallen.


Sub-Task-Plan — 6 Vertical Slices

  1. Spike + ADR-Trio: Live-Spike gegen Personio REST API v1 — echter Token-Fetch, echter Positions-Call, Field-Mapping-Annahmen (apply_url, description-struktur, paging) bestätigen. Anlegen von ADR-0031 (key-Modul-Pattern) und ADR-0032 (Service+Queue-Pattern); in-place Update von ADR-0029. Spike-Findings als Open-Items-Resolutions zurück ins PRD.
  2. PersonioApiClient + key-Modul: drupal/key als Composer-Dep hinzufügen + im Default-Sync aktivieren, zwei Key-Entities mit Env-Provider, PersonioApiClient-Service mit Token-Cache und Refresh-on-401. Unit-Tests für Token-Cache-Logik, Kernel-Tests mit Mock-HTTP.
  3. PersonioPositionMapper: JSON-Position-Object → Drupal-Field-Array, inkl. Term-Auto-Create und Description-Aggregation. Unit-Tests gegen Fixture-JSON-Responses (≥6 Cases: happy path, fehlende optional fields, mehrere Job-Sections, leere Salary-Range, neue Office-Picklist, neue Department-Picklist).
  4. PersonioVacancySyncer + QueueWorker + hook_cron: Sync-Orchestrierung mit synchronem Sweep, Queue-Befüllung, Worker-Verarbeitung. Kernel-Tests gegen echte Drupal-DB + Mock-HTTP (Insert; Update; Sweep mit 3 Test-Nodes; Empty-Response-Schutz).
  5. Cleanup Migrate-Legacy: migrate_plus/migrate_tools entfernen (Composer + info.yml + config/sync/-YAMLs), alte Migrate-Plugin-Files unter src/Plugin/migrate/ löschen, VacancyHardDeleteSubscriber entfernen, alter solcom_vacancy_cron-Hook (MigrateExecutable-Pfad) durch den neuen ersetzen. CONTEXT.md-Glossar-Eintrag aktualisieren.
  6. Operative Tooling: Drush-Commands solcom_vacancy:sync + solcom_vacancy:status, Watchdog-Channel-Definition, State-Tracking. Optional: README im Modul-Root mit Operations-Cheatsheet.

Slices 2-4 sind nach Slice 1 parallelisierbar. Slice 5 ist zerstörerisch (Composer/Files/Config) und wird atomar mit Slice 4 gemerged — damit existiert kein Zeitfenster, in dem alter und neuer Sync-Pfad parallel laufen. Slice 6 läuft additiv zu Slice 4. Empfohlene Deployment-Reihenfolge: 1 → 2 → 3 → 4+5 (atomar) → 6.

Testing Decisions

Tests folgen der Pyramide: deterministische Logik isoliert (Unit), Drupal-Container-abhängige Services und Pipelines im Kernel-Test (Mock-HTTP via http_client_factory-Service-Override), JSON-LD-Rendering im Functional-Test (bleibt vom Bestand). Keine FunctionalJavascript-Tests — keine UI-Änderungen.

TestTypCoverage-Ziel
PersonioPositionMapperTestUnitHappy Path; fehlende Felder; mehrere Job-Sections; leere Salary-Range; Picklist-Drift (Auto-Create); Section-Ausblendung
PersonioApiClientTestKernel + Mock-HTTPToken-Fetch; Token-Cache-Hit; Refresh bei 401; Logging-Verhalten; 5xx-Behandlung
PersonioVacancySyncerTestKernelInsert; Update; Sweep mit 3 Nodes; Empty-Response-Schutz; State-Tracking
PersonioPositionWorkerTestKernelItem-Verarbeitung; Mapper-Integration; Error-Handling (transienter 5xx → re-throw → Drupal-Retry)
JSON-LD-RenderingFunctionalAus dem Bestand übernommen, unverändert

Out of Scope

Open Items

BLOCKER 2026-05-15: Live-Spike gegen Personio hat die Plan-Kern-Annahme „authentifizierte REST liefert vollständigen Content" widerlegt. PRD und Plan brauchen ein Reframe (Hybrid / XML-only / Pause). Details + Beispiel-Bodies + verifizierte Fakten siehe Story-Hub F-280, Section Spike-Findings.

FAQ

Warum bauen wir das überhaupt jetzt?
Compliance- und IT-Security-seitige Anforderung: externe Datenquellen müssen authentifiziert angesprochen werden, mit nachvollziehbarem Audit-Trail und Service-Identity. Personio liefert dafür Service-Identity-Credentials, die wir in Drupal einbauen.
Warum kein Migrate-API?
Migrate-APIs Source-Plugin-Stack ist auf öffentliche oder einfach-authentifizierte Feeds optimiert. Personios JWT-Bearer-Flow zwingt uns ohnehin in Custom-Code (Custom Fetcher oder Authentication-Plugin). Damit verliert Migrate den Mehrwert (ID-Map, Process-Pipeline) gegenüber einem eigenen Service-Layer — der ist kleiner und ehrlicher. Begründung in ADR-0032.
Warum drupal/key statt direkt getenv()?
Audit-Trail in Drupal, UI für Rotation, Provider-pluggable (Env / File / Secret-Manager). ADR-0029 hatte ursprünglich direktes getenv() vorgesehen — das Pattern wird mit dieser Story konsolidiert und SF-Publication adoptiert es beim Bau. Begründung in ADR-0031.
Warum Queue-API bei nur ~50–100 offenen Stellen?
Headroom für Wachstum und Personio-Latenz. Synchrone Verarbeitung würde funktionieren; mit Queue-API bleibt der Sync-Run pro Cron-Cycle zeitlich gedeckelt (Annotation cron => ['time' => 60]), und wir haben Retry-Backoff bei transienten Fehlern „for free".
Wie wird die Deployment-Sequence garantiert?
Die Sub-Tasks 4 und 5 werden atomar gemerged: derselbe Commit, der den neuen hook_cron-Pfad aktiviert, entfernt auch den alten MigrateExecutable-Pfad. Damit ist kein Zeitfenster möglich, in dem beide Pulls parallel laufen.
Was passiert mit dem alten PRD 2026-05-02-personio-vacancy-integration-prd.html?
Es wird in der Frontmatter dieses PRDs als solcom:supersedes-prd referenziert und im alten PRD wird der Status auf superseded gesetzt mit Verweis auf dieses Dokument. Inhalte bleiben als historischer Kontext erhalten.
Was passiert mit den bestehenden Tests (z. B. VacancyDescriptionsConcatTest)?
Die fallen mit den Migrate-Process-Plugins weg. Die Test-Substanz (Description-Concat-Logik) wird in den neuen PersonioPositionMapperTest aufgenommen — die Test-Cases bleiben semantisch erhalten, nur die Test-Klasse und das System-Under-Test wechseln.
Was ist mit dem öffentlichen Personio-Feed unter https://solcom-gmbh.jobs.personio.de/xml?
Dieser Feed ist ein Personio-seitiges Konstrukt für SEO und Career-Portal-Embeds und bleibt unabhängig vom Drupal-Sync existent. Drupal greift künftig nicht mehr darauf zu.