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:
- Die Submission inkl. Files wird temporär in
private://applications/<uuid>/gespeichert; ein minimales Queue-Item ({submission_id}) landet in der Core-Queuesolcom_personio_application_queue; der Bewerber sieht sofort eine Drupal-Bestätigungsseite, die auf die folgende Personio-Bestätigungsmail hinweist. - Ein QueueWorker delegiert an den Service
PersonioApplicationClient, der die Bewerbung alsPOST /v1/recruiting/applicationsübermittelt. Bei Erfolg (2xx) wird die zurückgegebeneapplication_idpersistiert, der Status aufsentgesetzt, und die PII + Files werden aus Drupal gelöscht (Datensparsamkeit). - Fehler werden differenziert behandelt: vorübergehende Fehler (5xx/Netzwerk) mit Backoff erneut versucht, Rate-Limits (429) mit
Retry-Afterrespektiert, 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
- Als Bewerber möchte ich von einer Stellenausschreibung mit einem Klick zum Bewerbungsformular gelangen, damit der Bewerbungsprozess ohne Medienbruch abläuft.
- 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.
- Als Bewerber möchte ich den Stellentitel auf der Bewerbungsseite sehen, damit ich sicher bin, mich auf die richtige Stelle zu bewerben.
- Als Bewerber möchte ich meinen Lebenslauf hochladen (und optional Anschreiben/weitere Anhänge), damit meine vollständige Bewerbung übermittelt wird.
- 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.
- Als Bewerber möchte ich die Datenschutzhinweise bestätigen müssen, damit transparent ist, wie meine Daten verarbeitet werden.
- 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.
- 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.
- Als Bewerber möchte ich nicht versehentlich doppelt im Recruiting-System auftauchen, damit mein Bewerbungsstatus eindeutig bleibt.
- 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.
- Als Recruiter möchte ich benachrichtigt werden, wenn eine Bewerbung dauerhaft nicht zugestellt werden konnte, damit ich sie manuell nachreichen kann.
- 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.
- Als Recruiter möchte ich diese DLQ-Übersicht nur mit der passenden Berechtigung erreichen, damit Bewerberdaten geschützt bleiben.
- 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.
- 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.
- 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.
- 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.
- Als Entwickler möchte ich die API-Credentials sicher außerhalb der Versionskontrolle ablegen, damit keine Geheimnisse ins Repo gelangen.
- 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.
- 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).
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.
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.
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.
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: 2xx → sent + application_id persistieren + purge; 429 → Retry-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:
- Löschen (C+-only, superseded):
PersonioMailBuilder(SMTP-Body),PersonioWebhookController+ die Route/personio/webhook/application-created(exponiert,_access:'TRUE'— Security-Gap),PersonioWebhookCommands. - Refactoren (Scaffolding):
VacancyApplicationController,ApplicationQueueHandler(Enqueue-Guard),ApplicationQueueWorker(RuntimeException → DelayedRequeueException, SMTP → Client-Delegation, Scalar-State), Webform-Config (_meta-Array → separate Scalars), Smoke-Test (Inbox → API-Read-back). - Neu bauen:
PersonioApplicationClient,ApplicationPurger.
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):
- Kernel —
PersonioApplicationClient: Field-Mapping (Webform → API-Body), deterministische Idempotency-Key-Bildung, Status-Klassifizierung (2xx/429/5xx/4xx → Result-Typ) gegen einen gemockten HTTP-Client. - Kernel —
ApplicationPurger: nachpurge()sind Submission-Entity + Files entfernt, das Verzeichnis ist weg, und der Watchdog-Eintrag ist vorhanden und PII-frei. - Functional —
VacancyApplicationController: Sub-Route rendert, gültiger Submit erzeugt genau ein Queue-Item + private://-Verzeichnis. - Functional — DLQ-View-Access: HR-Rolle sieht die Liste, anonym → 403.
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
- Webhook-basierte ACK-Mechanik (Subscription, Receiver, HMAC-Verifikation) — durch Pfad A obsolet.
- E-Mail-Versand an ein Personio-Inbox-Postfach — entfällt mit Pfad A.
- Bewerbungsstatus-Synchronisation zurück nach Drupal (z. B. „im Prozess", „abgelehnt") — Personio-Backend bleibt das System of Record.
- Bewerber-Account / Login / „meine Bewerbungen" auf solcom.de.
- Änderungen am Inbound-Vacancy-Feed (ADR-0035, XML-Pulling) — davon unberührt.
- Das Apply-CTA-Field-Binding selbst (ADR-0014:
field_apply_url→ interne Sub-Route) ist als eigener Plan-Slice zu führen, nicht Teil der Übermittlungslogik dieses PRDs.
Further Notes
Aus dem Re-Grill (2026-05-29) als Plan-/Hygiene-Notizen mitzunehmen (kein Architektur-Trade-off, Details im Story-Hub F-100):
- G6 — Retry-Tiefe: Abweichung 5 vs. 3 Stufen vom
SfClient-Präzedenz im Plan kurz begründen. - G7 — Apply-CTA-Rewiring: als eigener Slice + QA-Test ausmodellieren (ADR-0014-Canvas-Eingriff).
- G8 — E15 PII-Downstream: Drupal→Personio→Salesforce-Bewerber-PII (IT-Landscape, „PII-Klassifikation?" offen) mit HR/Legal im AVV-Kontext klären.
- G9 — PRD-Status-Drift: Out-of-Scope-Klausel im breiten Personio-Vacancy-PRD als für diesen Slice aufgehoben markieren.
- G10 — AVV-Snapshot: fehlt unter
docs/context/(AK-17-Vorbedingung) — viasolcom-docs-ingestionschließen.
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)