docs/prds/2026-05-29-vacancy-application-personio-api-post-prd.html),
Architektur-Source-of-truth ADR-0050. Supersedes den C+-Plan
2026-05-25-002-feat-vacancy-application-form.html (Email-Inbox-Bridge + Webhook-ACK, obsolet).
Die vier Build-Slices mappen 1:1 auf die bereits refined Jira-Sub-Tasks
P2503-184 / 186 / 187 / 188 — kein Jira-Push aus diesem Plan. Build-Charakter:
Refactor-mit-Kill-Liste am bestehenden Modul solcom_vacancy_application (kein Rewrite).
Vier dünne vertikale Schnitte durch alle Layer (Webform-Config, Sub-Route, Queue, Service, Tests). Jeder Slice ist eigenständig demoable und mappt auf einen bestehenden Sub-Task. Die nicht-Code-Vorbedingungen P2503-189 (Tenant-Config), P2503-190 (AVV) und P2503-191 (Multiposting-Scope) sind bereits Fertig und liegen außerhalb der Build-Slices.
| Phase | Title | Sub-Task | Type | Blocked by |
|---|---|---|---|---|
| 1 | Submit landet in Personio (Tracer-Bullet, e2e demoable) | P2503-184 | AFK | — (P2503-189 done) |
| 2 | Idempotenz + GDPR-Purge bei API-2xx | P2503-186 | AFK | 1 |
| 3 | DLQ-Recovery für HR | P2503-187 | AFK | 1 |
| 4 | Smoke-Test + Kernel/Functional-Tests | P2503-188 | AFK | 1, 2, 3 |
Den bestehenden solcom_vacancy_application-Modul auf Pfad A umbauen. Neu: das Deep Module PersonioApplicationClient mit schmalem Interface submit(WebformSubmissionInterface): SubmitResult — Field-Mapping (Webform → API-Body inkl. custom_attributes), Multipart-/File-Assembly, HTTP-POST /v1/recruiting/applications, Response-Parsing (201 + application_id), injizierter HTTP-Client (gegen Mock testbar; gebaut analog zum Salesforce-SfClient). Refactor: VacancyApplicationController (Sub-Route /karriere/<slug>/bewerben im byte_theme, Kontext-Header mit Stellentitel, Danke-Seite), ApplicationQueueHandler (Enqueue beim Initial-Submit, Guard), ApplicationQueueWorker (Delegation an Client; RuntimeException → DelayedRequeueException). Löschen: die C+-Kill-Liste (siehe Sektion 03). Webform-State von _meta-Array → separate skalare Elemente. End-to-end: Submit auf der Sub-Route → ein Queue-Item → Worker POSTet → 201 → status=sent + application_id persistiert → Danke-Seite.
/karriere/<slug>/bewerben rendert im byte_theme-Branding mit sichtbarem Stellentitel.private://applications/<uuid>/.PersonioApplicationClient; bei 201 wird application_id persistiert und status=sent gesetzt.PersonioMailBuilder, kein PersonioWebhookController, keine /personio/webhook/*-Route in routing.yml.status, retry_count, sent_at, personio_application_id, tracking_hash; je '#access': false), kein _meta-Array.Deterministischer Idempotency-Key (= recomputeter tracking_hash aus Submission-UUID + Site-Salt) als Header an jedem POST; Personio dedupliziert server-seitig. Fallback bei fehlendem Server-Support: Pre-POST-Existence-Check via GET /v2/recruiting/applications, nur auf Retries. Neu: das Deep Module ApplicationPurger mit Interface purge(WebformSubmissionInterface): void — GDPR-Lösch-Sequenz (FileUsageInterface::delete() → $file->delete() → $submission->delete() → Verzeichnis-Removal) plus PII-freier Watchdog-Audit-Eintrag. Verdrahtung in den 2xx-Pfad des Workers.
ApplicationPurger Submission-Entity + Files + Verzeichnis.tracking_hash + application_id + Timestamp).Retry-/DLQ-State-Machine im Worker: retry_count wird per $submission->save() vor dem Werfen der DelayedRequeueException persistiert (der Zähler darf nie in der Queue-Payload liegen — delayItem() friert die Payload ein). Backoff-Schedule [60, 300, 900, 3600, 21600] s, max 5 Versuche. Failure-Klassifizierung: 2xx → sent; 429 → Retry-After respektieren; 5xx/Netzwerk → Backoff; 4xx außer 429 → sofort dlq_persistent (kein Retry-Burn) + HR-Notification. DLQ-Übersicht als View auf webform_submission (Filter status='dlq_persistent') via webform_views + views_bulk_operations; state-ändernde Aktionen (Re-Queue/Resolve) nur über POST + CSRF (VBO-Actions); Zugang nur mit Permission manage karriere dlq, anonym → 403.
retry_count und persistiert ihn via save() vor der DelayedRequeueException; kein Re-Enqueue-Loop (Enqueue-Guard hält).[60,300,900,3600,21600], max 5 Retries; 4xx (außer 429) → sofort dlq_persistent.status='dlq_persistent'; Re-Queue/Resolve sind POST + CSRF; anonym → 403, nur manage karriere dlq sieht die Liste.Drush-Smoke-Test-Command, das eine markierte Test-Bewerbung end-to-end durchschickt und in Personio per API-Read-back gegenprüft (Umbau des alten Inbox-Smoke-Tests auf API-Read-back). Kernel-Tests für PersonioApplicationClient (Field-Mapping, Idempotency-Key, Status-Klassifizierung gegen gemockten HTTP-Client — Vorlage: SfClient-Kernel-Tests) und ApplicationPurger (nach purge(): Submission + Files weg, Verzeichnis weg, Watchdog-Eintrag PII-frei). Functional-Tests für VacancyApplicationController (Sub-Route rendert, gültiger Submit → 1 Queue-Item + private-Verzeichnis) und DLQ-View-Access (HR sieht Liste, anonym → 403). API-Credentials via settings.local.php (außerhalb VCS).
PersonioApplicationClient Field-Mapping + Idempotency-Key + Status-Klassifizierung (2xx/429/5xx/4xx → Result-Typ) gegen Mock-HTTP-Client.ApplicationPurger entfernt Submission + Files + Verzeichnis, lässt PII-freien Watchdog-Eintrag.settings.local.php gelesen, nicht committet.Synchron für den Bewerber (Submit → Danke-Seite), asynchron für die Übermittlung (Core-Queue → Worker → Client → Personio). Die Webhook-/ACK-Schicht aus Pfad C+ entfällt — die API liefert die application_id synchron in der Response.
Backoff-Pfad reiht das Item nach Schedule erneut ein; der Enqueue-Guard verhindert, dass das Worker-save() ein zweites Item erzeugt. 4xx (außer 429) brennt keine Retries.
Der existierende C+-Code unter web/modules/custom/solcom_vacancy_application/ trägt alle drei FAIL-Findings (S-5/S-6/S-7) noch live und ist ungebaut für Pfad A. Die zu löschenden Services sind keine zu bewahrenden Best-Practice-Patterns, sondern Artefakte der durch ADR-0050 superseded Architektur — plus eine offen exponierte Webhook-Route. Das Scaffolding bleibt: Refactor, kein Rewrite.
| Disposition | Artefakt | Grund |
|---|---|---|
| DELETE | src/Service/PersonioMailBuilder.php | SMTP-Body, C+-only — Pfad A POSTet direkt. |
| DELETE | src/Controller/PersonioWebhookController.php + Route /personio/webhook/application-created | Exponiert (_access:'TRUE') — Security-Gap; ACK entfällt mit Pfad A. |
| DELETE | src/Commands/PersonioWebhookCommands.php | Webhook-Tooling, obsolet. |
| DELETE | tests/.../PersonioMailBuilderTest.php, PersonioWebhookControllerTest.php | Tests für gelöschten Code. |
| REFACTOR | src/Controller/VacancyApplicationController.php | Sub-Route + Kontext-Header + Danke-Seite bleiben, Inbox-Bezug raus. |
| REFACTOR | src/Plugin/WebformHandler/ApplicationQueueHandler.php | Enqueue-Guard (nur Initial-Submit). |
| REFACTOR | src/Plugin/QueueWorker/ApplicationQueueWorker.php | RuntimeException → DelayedRequeueException, SMTP → Client-Delegation, Scalar-State. |
| REFACTOR | config/install/webform.webform.vacancy_application.yml | _meta-Array → separate Scalars (fix S-6). |
| REFACTOR | src/Commands/PersonioSmokeTestCommands.php | Inbox-Check → API-Read-back. |
| REFACTOR | src/Controller/DlqAdminController.php | Ersetzen durch webform_views/VBO-View, sofern beta-kompatibel (fix S-7). |
| NEU | src/Service/PersonioApplicationClient.php | Deep Module: Submit-Logik isoliert testbar. |
| NEU | src/Service/ApplicationPurger.php | Deep Module: GDPR-Lösch-Sequenz + PII-freier Audit. |
Die Wurzeln der drei FAIL-Findings adressiert, nicht umgangen.
State-Store (fix S-6): Lifecycle-State liegt co-located an der Submission als separate skalare Webform-Elemente (status, retry_count, sent_at, personio_application_id, tracking_hash), je '#access': false — kein _meta-Array. Die S-6-Wurzel war der Array-Typ im Hidden-Element, nicht der Speicherort; Co-Location liefert die GDPR-Kopplung gratis (State stirbt/überlebt mit der Submission, kein Orphan-State).
Retry ohne Re-Enqueue-Loop (fix S-5): Worker erhöht retry_count und schreibt ihn per $submission->save() vor der DelayedRequeueException (der Throw verlässt processItem(); delayItem() friert die Payload ein — der Zähler darf nie in der Payload liegen). Der ApplicationQueueHandler reiht nur beim Initial-Submit ein (Guard: kein Enqueue, wenn status bereits gesetzt), sonst triggert jedes Worker-save() den Handler → Endlosschleife. Backoff via Core DelayedRequeueException($delay), Schedule [60, 300, 900, 3600, 21600] s, max 5 (Core-verifiziert: Cron::processQueue() ruft delayItem() bei dieser Exception).
Idempotenz: deterministischer 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.
DLQ-UI-Sicherheit (fix S-7): State-ändernde Aktionen (Re-Queue, Resolve) laufen ausschließlich über POST + CSRF (VBO-Actions oder Fallback ConfirmFormBase) — nie über state-ändernde GET-Routen. Zugang nur mit Permission manage karriere dlq (HR-Rolle), anonym → 403.
201+id? Idempotency-Key-Support?) — Spec dokumentiert oft nur den empfohlenen Pfad.SubmitResult.save() (S-5-Wiederkehr).status leer); Functional-Test, der genau ein Queue-Item pro Submit assertet.hook_webform_submission_delete-Cascade löscht Files unerwartet doppelt oder gar nicht.ApplicationPurger prüfen; Kernel-Test über die volle Lösch-Sequenz.webform_views / views_bulk_operations nicht beta-kompatibel für die DLQ-View.ConfirmFormBase-Action (POST + CSRF) statt VBO, gleiche Sicherheits-Semantik.Click-Pfade pro Akzeptanzkriterium — Quelle der Wahrheit für die Pre-PR-Acceptance-Stage (ADR-0037) und die Mensch-QA nach PR-Open. Host https://solcom.ddev.site; <slug> = eine real existierende Vacancy bzw. die markierte Test-Stelle.
| AK-# | URL | Action | Expected |
|---|---|---|---|
| AK-1 | https://solcom.ddev.site/karriere/test-stelle/bewerben |
Seite aufrufen | HTTP 200, byte_theme-Branding, Stellentitel „test-stelle" sichtbar im Kontext-Header. |
| AK-2 | https://solcom.ddev.site/karriere/test-stelle/bewerben |
Pflichtfelder ausfüllen, CV-PDF hochladen, Datenschutz anhaken, absenden | Redirect auf Danke-Seite mit Hinweis auf folgende Personio-Bestätigungsmail; drush queue:list zeigt 1 Item in solcom_personio_application_queue; private://applications/<uuid>/ existiert. |
| AK-3 | https://solcom.ddev.site/karriere/test-stelle/bewerben |
Datei > Max-Größe oder falscher Typ hochladen, absenden | Inline-Fehlermeldung am Upload-Feld; kein Queue-Item erzeugt. |
| AK-4 | CLI | ddev drush queue:run solcom_personio_application_queue nach gültigem Submit |
POST an Personio; bei 2xx: status=sent + personio_application_id gesetzt, danach Submission + Files gelöscht; Watchdog-Eintrag PII-frei (nur tracking_hash + application_id + Timestamp). |
| AK-5 | CLI | Worker mit gemocktem 4xx (außer 429) laufen lassen | status=dlq_persistent sofort (kein Retry-Burn); HR-Notification-Mail im drush-Maillog. |
| AK-6 | https://solcom.ddev.site/admin/karriere/dlq |
Als anonym aufrufen | HTTP 403. |
| AK-7 | https://solcom.ddev.site/admin/karriere/dlq |
Als HR-Rolle (Permission manage karriere dlq) aufrufen, „erneut versuchen" auf einer DLQ-Zeile |
Liste zeigt dlq_persistent-Bewerbungen mit Datei-Download; Re-Queue läuft über POST + CSRF (kein state-ändernder GET), Item zurück in der Queue. |
| AK-8 | CLI | ddev drush solcom:personio-smoke-test |
Markierte Test-Bewerbung wird durchgeschickt und per API-Read-back in Personio bestätigt; Exit-Code 0. |
201 + application_id? Wird ein Idempotency-Key-Header server-seitig respektiert? Wie verhält sich Retry-After bei 429? Per Live-Probe gegen Personio mit den Multiposting-Credentials zum Implementation-Start klären.custom_attributes-Felder vollständig ab? Abgleich gegen das bisherige Personio-Formular.field_apply_url → interne Sub-Route (ADR-0014-Canvas-Eingriff) ist nicht Teil der Übermittlungslogik dieses Plans und als eigener Slice + QA-Test zu führen (G7 aus dem Re-Grill).