P2503 · Karriere · F-100 · PRD

Bewerber-Flow: Drupal-native Bewerbung mit direktem Personio-API-POST

TL;DR. Bewerber sollen von einer Stellenausschreibung mit einem Klick auf ein Drupal-natives Bewerbungsformular gelangen und ihre Bewerbung dort absenden — ohne Weiterleitung auf eine fremde Seite. Die Bewerbung wird asynchron über die Drupal Core Queue an die Personio Recruiting API (POST /v1/recruiting/applications) übermittelt (Pfad A). Architektur-Source-of-truth: ADR-0050 (supersedes ADR-0047, Email-Inbox-Bridge). Dieses PRD fokussiert den Apply-Flow-Slice und hebt damit die Out-of-Scope-Klausel des breiteren Personio-Vacancy-Integration-PRD („kein Drupal-Formular") für diesen Slice auf.

Problem Statement

Heute endet die Customer-Journey eines Bewerbers auf solcom.de mit einem externen Link: Der „Bewerben"-Button auf einer Stellenausschreibung führt auf eine von Personio gehostete Seite (solcom-gmbh.jobs.personio.de bzw. karriere.solcom.de). Das ist ein Medienbruch — anderes Layout, andere Domain, kein SOLCOM-Branding, und ein Vertrauens-/Conversion-Verlust an genau der Stelle, an der der Bewerber zur Tat schreitet.

Ein erster Lösungsversuch (Pfad C+: Drupal-Formular, das die Bewerbung per E-Mail an ein Personio-Inbox-Postfach schickt und den Empfang per Webhook bestätigt) ist im Code-Review vom 2026-05-28 mit Acceptance: FAIL durchgefallen (HARD-Findings S-5/S-6/S-7) und schleppte unnötige Komplexität (Webhook-Subscription, Inbox-Mail-Parsing, SPF/DKIM-Abhängigkeit) mit. Mit der Gewährung des Personio-Multiposting-Scopes (P2503-191) ist der direkte API-Weg (Pfad A) erreichbar geworden, der diese Komplexität auflöst.

Solution

Eine Drupal-native Bewerbungsseite unter der internen Sub-Route /karriere/<slug>/bewerben, gerendert im SOLCOM-byte_theme-Branding, mit denselben Feldern wie das bisherige Personio-Formular (inkl. Pflicht-CV-Upload und Datenschutz-Bestätigung). Beim Absenden:

  1. Die Submission inkl. Files wird temporär in private://applications/<uuid>/ gespeichert; ein minimales Queue-Item ({submission_id}) landet in der Core-Queue solcom_personio_application_queue; der Bewerber sieht sofort eine Drupal-Bestätigungsseite, die auf die folgende Personio-Bestätigungsmail hinweist.
  2. Ein QueueWorker delegiert an den Service PersonioApplicationClient, der die Bewerbung als POST /v1/recruiting/applications übermittelt. Bei Erfolg (2xx) wird die zurückgegebene application_id persistiert, der Status auf sent gesetzt, und die PII + Files werden aus Drupal gelöscht (Datensparsamkeit).
  3. Fehler werden differenziert behandelt: vorübergehende Fehler (5xx/Netzwerk) mit Backoff erneut versucht, Rate-Limits (429) mit Retry-After respektiert, dauerhafte Fehler (4xx) sofort in eine Dead-Letter-Queue (DLQ) verschoben — mit Mail-Benachrichtigung an HR, damit keine Bewerbung unbemerkt verloren geht.

Die gesamte Webhook-/ACK-Schicht aus Pfad C+ entfällt, weil die API die Application-ID synchron in der Response liefert.

User Stories

  1. Als Bewerber möchte ich von einer Stellenausschreibung mit einem Klick zum Bewerbungsformular gelangen, damit der Bewerbungsprozess ohne Medienbruch abläuft.
  2. Als Bewerber möchte ich das Formular im vertrauten SOLCOM-Look auf der SOLCOM-Domain sehen, damit ich nicht das Gefühl habe, die Seite verlassen zu haben.
  3. Als Bewerber möchte ich den Stellentitel auf der Bewerbungsseite sehen, damit ich sicher bin, mich auf die richtige Stelle zu bewerben.
  4. Als Bewerber möchte ich meinen Lebenslauf hochladen (und optional Anschreiben/weitere Anhänge), damit meine vollständige Bewerbung übermittelt wird.
  5. Als Bewerber möchte ich bei einer zu großen oder falschen Datei eine klare Fehlermeldung erhalten, damit ich den Upload korrigieren kann, bevor ich absende.
  6. Als Bewerber möchte ich die Datenschutzhinweise bestätigen müssen, damit transparent ist, wie meine Daten verarbeitet werden.
  7. Als Bewerber möchte ich nach dem Absenden eine Bestätigungsseite sehen, die mir sagt, dass eine Bestätigungsmail folgt, damit ich weiß, dass meine Bewerbung angekommen ist.
  8. Als Bewerber möchte ich, dass meine Bewerbung auch dann ankommt, wenn das Recruiting-System kurz nicht erreichbar ist, damit ein technischer Ausfall mich nicht die Stelle kostet.
  9. Als Bewerber möchte ich nicht versehentlich doppelt im Recruiting-System auftauchen, damit mein Bewerbungsstatus eindeutig bleibt.
  10. Als Recruiter (HR) möchte ich, dass eine über die Website abgegebene Bewerbung im Personio-Recruiting-Backend wie eine reguläre Bewerbung erscheint, damit mein Arbeitsablauf unverändert bleibt.
  11. Als Recruiter möchte ich benachrichtigt werden, wenn eine Bewerbung dauerhaft nicht zugestellt werden konnte, damit ich sie manuell nachreichen kann.
  12. Als Recruiter möchte ich eine Übersicht der dauerhaft fehlgeschlagenen Bewerbungen mit Datei-Download und einer „erneut versuchen"-Aktion sehen, damit ich Ausfälle ohne Entwickler-Hilfe abarbeiten kann.
  13. Als Recruiter möchte ich diese DLQ-Übersicht nur mit der passenden Berechtigung erreichen, damit Bewerberdaten geschützt bleiben.
  14. Als Datenschutz-Verantwortlicher möchte ich, dass nach erfolgreicher Übermittlung die personenbezogenen Daten und Dateien auf der Website gelöscht werden, damit die Datensparsamkeit gewahrt bleibt.
  15. Als Datenschutz-Verantwortlicher möchte ich, dass jede Löschung einen PII-freien Audit-Eintrag hinterlässt, damit die Verarbeitung nachvollziehbar bleibt, ohne neue PII zu schaffen.
  16. Als Entwickler möchte ich die Übermittlungslogik (Field-Mapping, Idempotenz, Fehlerklassifizierung) in einem isoliert testbaren Service kapseln, damit ich sie ohne Live-API absichern kann.
  17. Als Entwickler möchte ich, dass ein Worker-internes Speichern des Retry-Zählers nicht versehentlich eine neue Queue-Einreihung auslöst, damit kein Endlos-Loop entsteht.
  18. Als Entwickler möchte ich die API-Credentials sicher außerhalb der Versionskontrolle ablegen, damit keine Geheimnisse ins Repo gelangen.
  19. Als Entwickler möchte ich einen Smoke-Test-Befehl, der eine markierte Test-Bewerbung end-to-end durchschickt und in Personio gegenprüft, damit ich die Integration vor jedem Release validieren kann.
  20. Als Site-Builder möchte ich verstehen, warum erfolgreiche Bewerbungen nicht im Webform-Results-Tab liegen, damit ich die Lösch-Semantik nicht für einen Bug halte.

Implementation Decisions

Module

Die Logik wird aus dem QueueWorker-Plugin in zwei Deep Modules (schmale Interfaces, viel gekapselte Funktion, isoliert testbar) gezogen; der Rest bleibt bewusst flach (Glue, der delegiert).

Deep Module

PersonioApplicationClient — schmales Interface submit(WebformSubmissionInterface): SubmitResult. Kapselt: Field-Mapping (Webform-Felder → API-Body inkl. custom_attributes), Multipart-/File-Assembly, Idempotency-Key-Berechnung, HTTP-POST, Response-Parsing (201 + application_id) und Failure-Klassifizierung (2xx/429/5xx/4xx → typisiertes Result). Gebaut analog zum bestehenden Salesforce-SfClient (ADR-0029/0033). Injizierter HTTP-Client → in Isolation gegen einen Mock testbar. Existiert heute noch nicht.

Deep Module

ApplicationPurger — schmales Interface purge(WebformSubmissionInterface): void. Kapselt die GDPR-Lösch-Sequenz (file_usage-Deregistrierung → File-Löschung → Submission-Löschung → Verzeichnis-Removal) plus den PII-freien Watchdog-Audit-Eintrag. Isoliert testbar mit einer Fixture-Submission.

Thin / Glue

ApplicationQueueWorker (Plugin) delegiert pro Item an den Client und treibt die Retry-/DLQ-State-Machine. ApplicationQueueHandler (WebformHandler) erzeugt das Queue-Item beim Initial-Submit (mit Enqueue-Guard). VacancyApplicationController rendert Sub-Route, Kontext-Header und Danke-Seite. Eine Drush-Command-Klasse stellt den Smoke-Test bereit.

Config

DLQ-Übersicht als View auf webform_submission, gefiltert auf status='dlq_persistent', via webform_views + views_bulk_operations — kein Custom-Controller, sofern beta-kompatibel.

State + Retry (G1 + G2)

State-Store: Der Lifecycle-State liegt co-located an der Submission als separate skalare Webform-Elemente (status, retry_count, sent_at, personio_application_id, tracking_hash), jeweils '#access': false — ausdrücklich kein _meta-Array. Die S-6-Wurzel war der Array-Typ im Hidden-Element, nicht der Speicherort; die Co-Location liefert GDPR-Kopplung gratis (State stirbt/überlebt mit der Submission, kein Orphan-State).

Retry ohne Re-Enqueue-Loop: Der Worker erhöht retry_count und schreibt ihn per $submission->save() vor dem Werfen der DelayedRequeueException (der Throw verlässt processItem(), und delayItem() friert die Queue-Payload ein — der Zähler darf daher nie in der Payload liegen). Der ApplicationQueueHandler reiht nur beim Initial-Submit ein (Guard: kein Enqueue, wenn das status-Element bereits gesetzt ist), sonst würde jedes Worker-save() den Handler re-triggern → Endlosschleife. Backoff via Core DelayedRequeueException($delay) mit Schedule [60, 300, 900, 3600, 21600] s, max 5 Versuche (Core-verifiziert: Cron::processQueue() ruft delayItem() bei dieser Exception).

Idempotenz

Jeder POST trägt einen deterministischen Idempotency-Key (= recomputeter tracking_hash aus Submission-UUID + Site-Salt) als Header; Personio dedupliziert server-seitig. Fallback bei fehlendem Server-Support: Pre-POST-Existence-Check via GET /v2/recruiting/applications, nur auf Retries. Damit legt ein Retry-nach-Timeout (erster POST erfolgreich, Response verloren) keine zweite Application an.

DLQ + GDPR-Purge

Failure-Klassifizierung: 2xxsent + application_id persistieren + purge; 429Retry-After respektieren; 5xx/Netzwerk → Backoff bis 5×; 4xx außer 429 → sofort dlq_persistent (kein Retry-Burn) + HR-Notification. (Anmerkung: ADR-0033/SfClient nutzt 3 Stufen — die 5 Stufen hier sind eine bewusste Abweichung für ein längeres Retry-Fenster im Apply-Flow.)

GDPR-Purge bei 2xx: FileUsageInterface::delete()$file->delete()$submission->delete() + Verzeichnis-Removal; ein PII-freier Watchdog-Eintrag (nur tracking_hash + application_id + Timestamp) bleibt. Cascade-Verhalten von hook_webform_submission_delete ist beim Sequenz-Entwurf zu prüfen.

DLQ-UI-Sicherheit: State-ändernde Aktionen (Re-Queue, Resolve) laufen ausschließlich über POST + CSRF (VBO-Actions oder, im Fallback, ConfirmFormBase) — nie über state-ändernde GET-Routen (das war S-7). Zugang nur mit Permission manage karriere dlq (HR-Rolle), anonym → 403.

Code-Disposition (Refactor-mit-Kill-Liste)

Der existierende C+-Code (web/modules/custom/solcom_vacancy_application/) trägt alle drei FAIL-Findings noch live und ist ungebaut für Pfad A. Disposition:

Begründung: Die zu löschenden Services sind keine zu bewahrenden Best-Practice-Patterns, sondern Artefakte der durch ADR-0050 superseded Architektur — plus eine offen exponierte Route. Das ist Refactor, kein Rewrite (das Scaffolding bleibt).

Testing Decisions

Was einen guten Test ausmacht: Getestet wird externes Verhalten an den schmalen Modul-Interfaces, nicht die interne Mechanik. Der HTTP-Verkehr wird gegen einen Mock-Client geführt — kein Live-API-Call im Test-Lauf. Assertions sind binär (Result-Typ, persistierter Status, gelöschtes Entity, 403), nicht „Methode X wurde aufgerufen".

Scope (= AK-15):

Prior Art: Die Kernel-Tests des Salesforce-Publication-Moduls (ADR-0029/0033) sind die Vorlage für den Mock-HTTP-Client-Aufbau und das Result-Assertion-Muster des PersonioApplicationClient.

Out of Scope

Further Notes

Aus dem Re-Grill (2026-05-29) als Plan-/Hygiene-Notizen mitzunehmen (kein Architektur-Trade-off, Details im Story-Hub F-100):

Offene Implementation-Phase-Probes (nicht blockierend, ADR-0050): API-Request-/Response-Schema (multipart? 201+id? Idempotency-Key-Support?), Retry-After-Verhalten, Field-Parität gegen die Referenz-Form. Per Live-Probe gegen Personio mit den Multiposting-Credentials zum Implementation-Start.

Jira: P2503-90 · Architektur: ADR-0050 · Story-Hub: F-100 · QA-Plan: im Story-Hub (17 AKs)