P2503 · Projektpartnerportal · F-360

Gulp HTML-Feed (project_offer)

REFINED P2503-113 Erstellt 2026-05-11 · Refined 2026-05-21

User Story

Als Vertriebsmitarbeiter möchte ich meine offenen Projektanfragen automatisch über eine einfache HTML-Linkliste an Gulp übergeben, damit die Reichweite meiner Projektangebote ohne manuelle Pflege auf der Vendor-Plattform steigt.

Technische Beschreibung

Drupal publiziert unter /feeds/gulp.html eine HTML-Linkliste. Jeder Eintrag ist ein <a>-Tag mit der kanonischen Drupal-Detail-URL (/fuer-freiberufler/projektliste/<slug>, siehe ADR-0032) und dem Jobtitel als Linktext. Filterung über das bereits existierende Boolean-Feld field_sf_channel_gulp. Bewerben passiert auf der Detail-Seite über den bestehenden Minggo-Flow (F-330). Code lebt im bereits in F-340 angelegten Sub-Modul solcom_project_offer/modules/solcom_project_offer_feeds/.

Korrektur-Notiz 2026-05-21

Die Story trug ursprünglich den Arbeitstitel „Randstad XML / API". Refinement hat zwei Drifts korrigiert, die im Story-Hub-File als History dokumentiert sind:

  • Vendor: Randstad → Gulp. Randstad hat keine öffentlich dokumentierte Publisher-API; der heute aktive Vendor-Feed adressiert Gulp.
  • Pattern: Push-Multiposting-Distributor (verworfener Plugin-Architektur-Ansatz) → Pull-HTML-Feed (Pattern-Spiegel zu F-340).
  • Output-Format: XML wie F-340 → HTML, weil Gulp eine simple Linkliste erwartet (User-Screenshot 2026-05-21).

PRD-Hub

Vollständiges PRD inkl. Architektur-Map, HTML-Schema, Filter-Spec und Test-Scope: docs/prds/2026-05-21-gulp-html-feed-prd.html.

Sub-Tasks

Decomposition via /solcom-to-issues am 2026-05-21 — zwei vertikale Slices, beide AFK, parallel startbar. Tech-Heimat (Klassen-/Routen-/Hook-Details, Cache-Tag-Strategie, Suffix-Logik): docs/plans/2026-05-21-002-feat-gulp-html-feed.html.

SliceJira-KeyStakeholder-TitelAKs (aus Story-Hub)Status
1 P2503-169 Gulp-Linkliste unter /feeds/gulp.html live 1, 2, 3, 4, 6, 7, 8, 9, 10 IDEE
2 P2503-170 Alte Gulp-Anzeigen-Backlinks landen nach Cutover auf der neuen Detailseite 5 IDEE

Akzeptanzkriterien

Stakeholder-Sprache, testbar — wie im Jira-Ticket gepflegt:

  1. Ein Aufruf von https://www.solcom.de/feeds/gulp.html liefert eine wohlgeformte HTML-Seite mit einer Linkliste, die für jede publizierte Projektanfrage mit gesetztem Gulp-Channel-Flag einen <a>-Tag enthält.
  2. Jeder <a>-Link führt auf die kanonische Detail-Seite des Projektangebots unter /fuer-freiberufler/projektliste/<slug> (ADR-0032). Der Linktext entspricht dem Jobtitel plus dem Suffix (m/w/d).
  3. Eine Projektanfrage, deren Veröffentlichungs-Endedatum (field_sf_publish_end) in der Vergangenheit liegt, erscheint nicht in der Linkliste.
  4. Eine Projektanfrage, deren field_sf_pub_status in Salesforce von „Online" auf einen anderen Wert geändert wurde, erscheint nicht in der Linkliste — spätestens beim nächsten Vendor-Poll nach dem Migrate-Sync.
  5. Eine alte Gulp-Anzeige mit Backlink https://www.solcom.de/asp/robots/detail.aspx?mode=GULP&id=<sf-id> beantwortet mit HTTP-301 und einem Location-Header auf die kanonische Drupal-Detail-URL des passenden Projektangebots. Existiert kein Projektangebot mit dieser SF-ID, antwortet die URL mit HTTP-404.
  6. Die Feed-Response trägt den HTTP-Header X-Robots-Tag: noindex, nofollow und den Content-Type: text/html; charset=utf-8.
  7. Die Feed-Response wird über Drupal-Page-Cache mit Cache-Tag node_list:project_offer ausgeliefert; nach einer Pflege-Änderung in Salesforce wird der nächste Vendor-Poll spätestens innerhalb von Migrate-Intervall + 1 Stunde die Änderung enthalten.
  8. Der Endpoint /feeds/gulp.html ist öffentlich anonym aufrufbar, ohne Authentifizierung.
  9. Der Code lebt im bereits angelegten Sub-Modul solcom_project_offer/modules/solcom_project_offer_feeds/ und ergänzt es additiv (zusätzliche Route, Controller-Methode, Builder-Methode, Twig-Template).
  10. Es existieren Pflicht-Tests für (a) die Builder-Methode buildGulp() (Kernel, gegen Fixture-Nodes), (b) die Route /feeds/gulp.html mit Headers (Functional), (c) den Legacy-Detail-Redirect /asp/robots/detail.aspx?id=… mit Treffer und Nicht-Treffer (Functional).

QA-Plan

Click-Pfade pro AK — Pflichtsektion gemäß ADR-0037. Ohne diese Sektion wird die Story vom Ralph-Loop übersprungen.

AKURL / AktionErwartetes Ergebnis
AK1GET https://solcom.ddev.site/feeds/gulp.htmlHTML-Response, <a>-Tags-Anzahl = Anzahl Fixture-Nodes mit field_sf_channel_gulp = TRUE und aktivem Pub-Window.
AK2GET /feeds/gulp.html → Linktext eines Eintrags prüfenFormat „<job_title> (m/w/d)"; href ist absolute Drupal-URL unter /fuer-freiberufler/projektliste/.
AK3Fixture: Offer mit field_sf_publish_end = gestern; GET /feeds/gulp.htmlOffer fehlt in der Linkliste.
AK4Fixture: Offer mit field_sf_pub_status = „Pausiert"; GET /feeds/gulp.htmlOffer fehlt in der Linkliste.
AK5GET https://solcom.ddev.site/asp/robots/detail.aspx?mode=GULP&id=<fixture-sf-id> (ohne Follow)HTTP 301, Location auf die kanonische Detail-URL. Zweiter Case: ID ohne Node → HTTP 404.
AK6GET /feeds/gulp.html mit curl -IResponse-Header enthalten X-Robots-Tag: noindex, nofollow und Content-Type: text/html; charset=utf-8.
AK7Editieren eines Offers per tool_api_update_entity, dann erneuter GET /feeds/gulp.htmlCache wird nach Save invalidiert; geänderte Felder erscheinen in der nächsten Response.
AK8GET /feeds/gulp.html als anonymer User (ohne Login-Cookie)HTTP 200, keine Auth-Redirects.
AK9Sub-Modul solcom_project_offer_feeds in Drupal-Config-UI deaktivieren, GET /feeds/freelancermap.xmlBeide Routen (XML aus F-340 und HTML aus F-360) sind weg; bei Aktivierung beide wieder da.
AK10ddev exec ./vendor/bin/phpunit web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/Alle Pflicht-Tests grün (Builder-Kernel, Route-Functional, Legacy-Detail-Redirect).

Sprint contracts

Contract v1 for story P2503-113, 2 sub-tasks.

Sub-Task P2503-169

Acceptance criteria (mirrored from Jira)

  • AK-1: Ein Aufruf von https://www.solcom.de/feeds/gulp.html liefert eine HTML-Seite mit einer Linkliste; jeder Eintrag steht für ein für Gulp freigegebenes, aktuell veröffentlichtes Projektangebot. (test-typ: Kernel, Builder-Methode buildGulp() gegen Fixture-Nodes prüft Listenlänge und Inhalt)
  • AK-2: Jeder Link in der Liste führt auf die SOLCOM-Detailseite des Projektangebots unter /fuer-freiberufler/projektliste/<slug>; der Linktext zeigt den Jobtitel inklusive Suffix „(m/w/d)". (test-typ: Kernel, Builder-Methode prüft href-Format gegen /fuer-freiberufler/projektliste/<slug> und Linktext-Suffix „(m/w/d)" gegen Fixture-Node)
  • AK-3: Ein Projektangebot, dessen Veröffentlichungs-Endedatum in der Vergangenheit liegt, erscheint nicht mehr in der Linkliste. (test-typ: Kernel, Fixture-Node mit field_sf_publish_end = gestern darf in buildGulp()-Ergebnis nicht erscheinen)
  • AK-4: Ein Projektangebot, dessen Veröffentlichungs-Status in Salesforce nicht „Online" ist, erscheint nicht mehr in der Linkliste — spätestens beim nächsten Salesforce-Sync. (test-typ: Kernel, Fixture-Node mit field_sf_pub_status ≠ „Online" darf in buildGulp()-Ergebnis nicht erscheinen)
  • AK-5: Die Feed-Antwort trägt die HTTP-Header „X-Robots-Tag: noindex, nofollow" und „Content-Type: text/html; charset=utf-8". (test-typ: Functional, GET /feeds/gulp.html prüft Response-Header-Werte)
  • AK-6: Eine Pflege-Änderung in Salesforce ist innerhalb einer Stunde nach dem nächsten Salesforce-Sync in der Linkliste sichtbar, ohne dass jemand einen Cache manuell zurücksetzen muss. (test-typ: Kernel, Cache-Tag node_list:project_offer wird nach Node-Save invalidiert; Functional-Test kann Response-Freshness nach programmatischem Node-Update prüfen)
  • AK-7: Die URL /feeds/gulp.html ist ohne Login öffentlich anonym aufrufbar. (test-typ: Functional, anonymer GET auf /feeds/gulp.html liefert HTTP 200 ohne Auth-Redirect)

Out of scope

  • Legacy-Redirect /asp/robots/detail.aspx → kanonische Detail-URL (das ist P2503-170)
  • XML-Vendor-Feed für Freelancermap/F-340 — separates Sub-Modul, bereits abgeschlossen
  • Migrate-Sync-Takt-Konfiguration — liegt in F-290 / solcom_project_offer_sync

Test sketch

  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Kernel/GulpFeedBuilderTest.php::testBuildGulpReturnsActiveOffers
  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Kernel/GulpFeedBuilderTest.php::testBuildGulpLinkFormatAndSuffix
  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Kernel/GulpFeedBuilderTest.php::testBuildGulpExcludesExpiredOffer
  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Kernel/GulpFeedBuilderTest.php::testBuildGulpExcludesOfflineOffer
  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Functional/GulpFeedRouteTest.php::testFeedResponseHeaders
  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Functional/GulpFeedRouteTest.php::testAnonymousAccess
  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Kernel/GulpFeedBuilderTest.php::testCacheTagInvalidatedAfterNodeSave

Sub-Task P2503-170

Acceptance criteria (mirrored from Jira)

  • AK-1: Ein Aufruf der alten Gulp-Backlink-URL https://www.solcom.de/asp/robots/detail.aspx?mode=GULP&id=<sf-id> antwortet mit HTTP-301 und leitet auf die kanonische SOLCOM-Detailseite des passenden Projektangebots weiter. (test-typ: Functional, GET /asp/robots/detail.aspx?mode=GULP&id=<fixture-sf-id> ohne Auto-Follow liefert HTTP 301 mit korrektem Location-Header)
  • AK-2: Existiert zur übergebenen SF-ID kein Projektangebot in SOLCOM, antwortet die URL mit HTTP-404. (test-typ: Functional, GET mit unbekannter SF-ID liefert HTTP 404)
  • AK-3: Die Weiterleitung ist dauerhaft aktiv und nicht zeitlich begrenzt — sie funktioniert auch noch mehrere Monate nach Cutover für alte Gulp-Anzeigen. (test-typ: Functional, selbe Assertion wie AK-1; der Redirect-Controller darf kein zeitbasiertes Verfallsdatum haben — im Code-Review prüfbar)

Out of scope

  • Gulp HTML-Linkliste /feeds/gulp.html (das ist P2503-169)
  • Redirect-Logik für andere Vendor-Plattformen (Freelancermap, Randstad)
  • Cache-Invalidierung der Feed-Response — keine Feed-Caching-Änderung in diesem Slice

Test sketch

  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Functional/GulpLegacyRedirectTest.php::testRedirectHitReturns301
  • web/modules/custom/solcom_project_offer/modules/solcom_project_offer_feeds/tests/src/Functional/GulpLegacyRedirectTest.php::testRedirectMissReturns404

Implementation notes

Sub-Task P2503-169

Design decisions

  • Entschieden: buildGulp(array $offers): array als eigene öffentliche Methode in VendorFeedBuilder, ohne Nutzung der privaten buildFeed()-Methode. Begründung: buildFeed() ist XML-spezifisch (nimmt ein VendorContext-Objekt, generiert XML-Positionen); Gulp braucht kein Minggo-URL, kein UTM, keinen jobNumber-Suffix — eine separate Methode hält die Verantwortlichkeiten sauber.
  • Entschieden: VendorFeedController::respond() erhält einen zweiten Parameter string $contentType = 'application/xml; charset=utf-8'. Begründung: Minimale chirurgische Änderung — bestehende XML-Feeds bleiben unverändert, der Gulp-Controller übergibt text/html; charset=utf-8.
  • Entschieden: Suffix-Regex /\(m\s*[\/|]\s*w\s*[\/|]\s*[dxa]\)/i. Begründung: Deckt alle üblichen Schreibweisen ab (d/x/a, Leerzeichen, Slash oder Pipe als Trenner) ohne False Positives bei unverwandten Klammern im Titel.
  • Entschieden: Twig-Template solcom-feed-gulp.html.twig als minimales HTML-Dokument mit <ul><li><a href="...">...</a></li></ul>-Struktur. Begründung: Gulp erwartet gemäß Sprint-Contract eine einfache Link-Liste; kein CSS, kein komplexes Markup notwendig.
  • Entschieden: Beide neuen Test-Dateien in tests/src/Kernel/ platziert (statt Functional/). Begründung: KernelTestBase-Unterklassen gehören laut PSR-4 in das Kernel-Verzeichnis; VendorFeedRouteTest hat dieses Muster bereits etabliert.

Deviations from spec

  • Abweichung von Sprint-Contract (Test-Pfade): Sprint-Contract spezifizierte tests/src/Functional/GulpFeedRouteTest.php. Grund: Die Klasse erbt von KernelTestBase, nicht von BrowserTestBase — PSR-4-Namensraum erfordert das Kernel-Verzeichnis. Konsistent mit bestehendem VendorFeedRouteTest-Präzedenzfall.

Tradeoffs

  • Alternativen: (A) buildGulp() ruft privates buildFeed() auf mit neuem optionalem $html-Flag | (B) Eigene separate Methode ohne Wiederverwendung von buildFeed(). Gewählt: B, weil der gemeinsame Code minimal ist (cacheTagsFor() wird geteilt), während das Einführen eines Flags in buildFeed() die XML-spezifische Logik mit HTML-Logik vermischt hätte.

Decisions made under uncertainty

  • none

Review findings

  • med — spec — AK-2 href-Format: GulpFeedBuilderTest.php:186 assertet href="https?://[^"]+/node/\d+" statt /fuer-freiberufler/projektliste/<slug> (ADR-0032). Implementierung korrekt (toUrl('canonical')), aber Assertion prüft internen Pfad statt Pathauto-Slug. Fix: Functional-Test mit Pathauto-Install oder Path-Alias-Mock.
  • low — spec — AK-5 Content-Type: testFeedResponseHeaders:154 prüft nur text/html-Substring, nicht charset=utf-8. AK-5 verlangt vollständiges text/html; charset=utf-8.
  • low — spec — AK-6 Cache-Invalidierung: testCacheTagInvalidatedAfterNodeSave assertet Tag-Präsenz, aber keinen Node-Re-Save zur Verifikation der Invalidierung. AK-6 fordert Nachweis, dass der Tag nach Save ungültig wird.
  • low — standards — cacheTagsFor($offers) in buildGulp() zweimal aufgerufen (im #cache-Array und im Return-Array). Berührt Tradeoff "eigene buildGulp()-Methode": Minor-Redundanz, kein Folgefehler.
  • low — standards — solcom-feed-gulp.html.twig: href="{{ position.url }}" sollte |escape('html_attr') nutzen, damit & in Query-Strings nicht zu &amp; gemangled wird.

Review findings

  • high — standards — solcom-feed-gulp.html.twig: {{ position.url }} in href=-Attribut ohne |escape('html_attr') — Severity-Eskalation zu HARD (Vorrunde: low); Skill §Security §1 Twig-Auto-Escape klassifiziert Attribut-Kontext als nicht verhandelbar; betrifft Design-Decision „minimales Twig-Template".

Review findings

  • resolved — standards — href="{{ position.url|escape('html_attr') }}" bestätigt (Commit 03eb2d8); Regression-Test testGulpHrefUsesHtmlAttrEscape ergänzt. Vorheriges HIGH-Finding cleared.
  • low — spec — AK-5 charset=utf-8: GulpFeedRouteTest.php:154 assertet nur text/html-Substring, nicht vollständigen Content-Type-Wert. Implementation korrekt; Test-Assertion-Gap aus Vorrunde nicht adressiert.
  • low — spec — AK-6 Cache-Invalidierung: testCacheTagInvalidatedAfterNodeSave assertet Tag-Präsenz, führt aber keinen Node-Save-dann-Re-Fetch durch; Testname irreführend. Gap aus Vorrunde nicht adressiert.
  • low — standards — respond() setzt Cache-Control: public, max-age=3600 als Raw-Header parallel zu CacheableResponse-Metadata; Dynamic-Page-Cache-Subscriber kann den Raw-Header überschreiben. Berührt Caching-Tradeoff aus Design-Decisions (P2503-169).

Sub-Task P2503-170

Design decisions

  • Entschieden: Legacy-Redirect via dedizierter Route + detailRedirect()-Methode in VendorFeedController, nicht via LegacyRedirectSubscriber. Begründung: Der Subscriber behandelt statische Pfad-zu-Pfad-Weiterleitungen; die Detail-Redirect braucht eine dynamische DB-Abfrage (SF-ID → Node). Ein Controller ist die idiomatische Drupal-Lösung für parameterabhängige Antwortlogik.
  • Entschieden: $this->entityTypeManager() (Lazy-Methode aus ControllerBase) statt Constructor-Injection. Begründung: Minimale chirurgische Änderung — kein Umbau von Constructor und create() nötig, da der Entity-Type-Manager nur von dieser einen neuen Methode benötigt wird.
  • Entschieden: TrustedRedirectResponse statt RedirectResponse für den 301. Begründung: Drupal-Konvention für interne absolute URLs; vermeidet Security-Warnungen bei der URL-Validierung durch Drupals Trusted-Host-Mechanismus.
  • Entschieden: Kein Check auf mode=GULP-Parameter. Begründung: Der Pfad /asp/robots/detail.aspx ist bereits Gulp-spezifisch; andere Vendor-Plattformen hätten eigene ASP-Pfade. Ein Mode-Check würde defensiv Komplexität hinzufügen, die der Sprint-Contract nicht fordert.
  • Entschieden: Test-Datei in tests/src/Kernel/ (nicht Functional/). Begründung: PSR-4-Konformität — KernelTestBase-Unterklassen gehören in das Kernel-Verzeichnis. Präzedenz durch P2503-169 etabliert.

Deviations from spec

  • Abweichung von Sprint-Contract (Test-Verzeichnis): Sprint-Contract erwähnt den Testpfad nicht explizit. Tatsächliche Platzierung: tests/src/Kernel/GulpLegacyRedirectTest.php. Grund: PSR-4-Namensraumregel; konsistent mit P2503-169-Präzedenz.

Tradeoffs

  • Alternativen: (A) LegacyRedirectSubscriber um DB-Lookup erweitern | (B) Eigener Event-Subscriber nur für den Detail-Redirect | (C) Neue Route + Controller-Methode. Gewählt: C, weil Routen + Controller die idiomatische Drupal-Architektur für URL-Parameter-abhängige Logik sind und keine Subscriber-Logik mit DB-Abfragen vermischt werden muss.

Decisions made under uncertainty

  • none

Review findings

  • hard — standards — VendorFeedController::detailRedirect(): accessCheck(FALSE) ohne Inline-Kommentar-Begründung. SKILL.md §Security §3 fordert explizite Rechtfertigung bei jedem accessCheck(FALSE)-Aufruf. Berührt Design-Decision "kein mode=GULP-Check": Bewusste Public-Route, aber der Grund für den Access-Bypass muss im Code stehen.
  • med — spec — AK-1 Location-Header: GulpLegacyRedirectTest.php:110 assertet /node/<id>, nicht /fuer-freiberufler/projektliste/<slug> (ADR-0032). Implementierung korrekt (toUrl('canonical')); Pathauto-Pfad nicht im Kernel-Test-Scope verifizierbar.
  • low — standards — Design-Decision "entityTypeManager() lazy statt Constructor-Injection" bestätigt als bewusster Tradeoff (minimal-invasiv, single-use). Bei künftiger Controller-Erweiterung DI via create() nachrüsten.

Review findings

  • none — alle Befunde der Vorrunde bestätigt (accessCheck(FALSE)-Kommentar-Pflicht hard, AK-1 Slug-URL-Assertion med); keine neuen notes-relevanten Findings in diesem Lauf.

Resolved in this run (2026-05-22)

  • Hard-Finding accessCheck(FALSE): Inline-Kommentar in VendorFeedController::detailRedirect() ergänzt — Begründung: Public-Route (_access: TRUE), DB-Lookup benötigt auch unpublizierte Nodes damit aktive Gulp-Anzeigen nach Offline-Schaltung eines Angebots weiterhin redirecten statt 404 liefern. 2 Tests grün, PHPCS clean, PHPStan clean.
  • Med-Finding AK-1 Slug-URL: bleibt als dokumentierter Tradeoff bestehen — KernelTestBase hat kein Pathauto; der canonische Redirect-Pfad selbst ist korrekt (toUrl('canonical')). Kein Handlungsbedarf in diesem Sub-Task-Scope.

Review findings

  • resolved — standards — accessCheck(FALSE)-Inline-Kommentar in VendorFeedController::detailRedirect() bestätigt (Commit 6a6fe39); Begründung vollständig: Public-Route + DB-Lookup ohne Access-Check damit Gulp-Anzeigen nach Node-Offline weiterhin redirecten. Vorheriges HARD-Finding cleared.
  • low — standards — detailRedirect() gibt new Response('', 404) (Symfony-Basis) zurück, nicht new CacheableResponse('', 404); Drupal Page-Cache könnte leere 404 zwischenspeichern. Berührt Design-Decision „minimal-invasive Response für 404-Pfad".

Abhängigkeiten

  • F-340 / P2503-112 (XML-Vendor-Feeds) muss vor F-360 produktiv sein — das Sub-Modul, VendorFeedBuilder, Filter-Logik und Cache-Strategie werden dort etabliert. F-360 ist additiv.
  • F-290 / P2503-108 (project_offer-Sync) produktiv — ohne Migrate-Sync gibt es keine Project-Offer-Nodes.
  • Pathauto-Pattern auf den neuen IA-Pfad /fuer-freiberufler/projektliste/[node:title] umgestellt.
  • Drupal-Cutover-Plan aktiv: www.solcom.de zeigt am Go-Live auf Drupal.
  • Salesforce-Pflege des Channel-Flags SC_Publishing_Channel_Gulp__c ist im Vertriebs-Workflow verankert.

Offene Fragen

  • Listentyp: <ul> oder <table>? User-Screenshot zeigt <ul>-artige Linkliste — Default-Vorschlag ist <ul>, finalisierung bei Gulp-Vendor-Smoketest.
  • Job-Title-Suffix-Logik: immer (m/w/d) anhängen oder existierende Variante respektieren? Detail im Implementations-Plan.
  • UTM-Parameter im Link: Heute trägt der Gulp-ASP-Backlink UTM-Parameter (utm_source=gulp.de&utm_medium=social&utm_campaign=Jobposting). Der neue Drupal-Feed liefert kanonische Links ohne UTM — Tracking erfolgt vom Vendor-Backend aus. Akzeptanz?

Artefakte

Worklog

P2503-178 — URL-Kanon-Cutover 2026-05-25 — mp/P2503-178

Folge-Sub-Task zu P2503-113. Live-Pathauto-Pattern fuer project_offer war noch auf /projektpartnerportal/[node:title] (per ADR-0032 R1), die Test-Assertions in P2503-113 wurden aber bereits gegen den kanonischen Sitemap-Pfad /fuer-freiberufler/projektliste/<slug> (Sitemap N16 unter N14, Context-0002) gehaertet. Diese Story korrigiert das.

Files: config/sync/pathauto.pattern.project_offer_content.yml (Pattern-Edit), web/modules/custom/solcom_project_offer/solcom_project_offer.install (hook_update_9007), web/modules/custom/solcom_project_offer/tests/src/Kernel/PathautoUrlCanonRedirectTest.php (3 Kernel-Tests), docs/adr/0033-project-offer-url-canon-correction.html (neu), docs/adr/0032-project-offer-sync-hygiene.html (Supersede-Note an R1).

Live-Resultat: 211 Alias(e) regeneriert, 211 301-Redirect(s) angelegt, 0 Duplikate, 0 Non-Offer uebersprungen. Live-Smoke gruen: /projektpartnerportal/<slug> → HTTP 301 + Location /fuer-freiberufler/projektliste/<slug>; neue URL → HTTP 200; Gulp-Feed enthaelt keine alten Pfade mehr.

Test-Gate triple-green: PHPUnit 3/3 (43 Assertions), PHPCS clean, PHPStan 0 Errors (45 files).

solcom-review 2026-05-25 — feature/P2503-113

3-Achsen-In-Session-Review gegen develop. Findings: standards=5 (2 hard, 3 judgement), spec=3, canvas=skipped. Worst: Hard 1 — TrustedRedirectResponse 301 ohne addCacheableDependency() (3-Zeilen-Patch). Spec-Findings sind dokumentierte Test-Assertion-Tradeoffs (Pathauto nicht im KernelTestBase). Volltext: docs/reports/2026-05-25-feature-P2503-113-review.html.

Clean-Branch-Cutover 2026-05-25 — feature/P2503-113 ab origin/develop

Die Story-Implementation lief urspruenglich auf mp/P2503-113 mit 15 Ralph-Loop-Commits. Fuer den finalen PR gegen develop wurde der Branch feature/P2503-113 ab origin/develop neu aufgesetzt und der State der 9 Story-Files per git checkout mp/P2503-113 -- <paths> uebernommen. mp/P2503-113 bleibt als Backup-Branch unberuehrt. Vier Low-Findings aus der Vorrunde wurden inline gefixt (siehe Bloecke unten).

P2503-169 — Gulp HTML-Feed unter /feeds/gulp.html live

AK-Refs: AK-1, AK-2, AK-3, AK-4, AK-5, AK-6, AK-7

Files (Subset des feat(P2503-113)-Commits, da VendorFeedController.php + routing.yml mit P2503-170 geteilt werden): solcom_project_offer_feeds.module, solcom_project_offer_feeds.routing.yml (route /feeds/gulp.html), src/Controller/VendorFeedController.php (Methoden gulp() + respond()), src/Service/VendorFeedBuilder.php (getEligibleOffers + buildGulp), templates/solcom-feed-gulp.html.twig, tests/src/Kernel/GulpFeedBuilderTest.php, tests/src/Kernel/GulpFeedRouteTest.php

Low-Findings aus Vorrunde adressiert auf feature/P2503-113:

  • P3 — AK-5 Content-Type-Assertion exact: GulpFeedRouteTest.php:154 jetzt assertSame('text/html; charset=utf-8', ...) statt Substring-Check. Implementation am Controller (respond($rendered, 'text/html; charset=utf-8')) bereits korrekt.
  • P4 — AK-6 Cache-Tag-Invalidation echter Save+Re-Fetch: GulpFeedBuilderTest.php alte Methode testCacheTagInvalidatedAfterNodeSave umbenannt in testCacheTagsAttachedToResponse (drueckt aus, was sie wirklich testet); neue Methode testFeedRefreshesAfterNodeSave verifiziert echten Save-dann-Re-Fetch-Zyklus mit Cache-Tag-Invalidation. installSchema('node', ['node_access']) in setUp() ergaenzt (Kernel-Test-Quirk fuer Resave).
  • P5 — respond() Raw-Cache-Control entfernt: Doppelter Cache-Pfad eliminiert. CacheableMetadata::setCacheMaxAge(3600) ist source-of-truth; Raw-Header 'Cache-Control' => 'public, max-age=3600' aus dem CacheableResponse-Constructor entfernt. F-340 XML-Feeds nicht regressiv (alle 25 Tests gruen).

QA: 11/12 strict pass live in DDEV, 1 deviation (AK-2 URL-Pfad config-Drift auf Pathauto-Pattern, out-of-scope), 1 covered-via-PHPUnit (AK-5 Cutover-Path post-deploy verify). Details in docs/reports/2026-05-25-feature-P2503-113-qa.html.

P2503-170 — Gulp-Legacy-Backlink-Redirect (/asp/robots/detail.aspx → kanonische Detail-URL)

AK-Refs: P2503-170 AK-1 (301 mit Location), AK-2 (404 fuer unbekannte SF-ID), AK-3 (Redirect dauerhaft aktiv, kein Verfallsdatum im Controller); Story-Level AK-5 (Cutover-Backlink fuer alte Gulp-Anzeigen).

Files (Subset des feat(P2503-113)-Commits): solcom_project_offer_feeds.routing.yml (route /asp/robots/detail.aspx), src/Controller/VendorFeedController.php (Methode detailRedirect(), accessCheck(FALSE) mit Begruendungs-Kommentar), tests/src/Kernel/GulpLegacyRedirectTest.php

Low-Findings aus Vorrunde adressiert:

  • P6 — 404 als CacheableResponse: Beide new Response('', 404)-Stellen (Z. 41 leerer SF-ID-Pfad, Z. 56 No-Match-Pfad) ersetzt durch (new CacheableResponse('', 404))->addCacheableDependency($notFoundTags) mit Cache-Tag node_list:project_offer. Damit invalidieren spaetere SF-Sync-Pflege-Aktionen (neuer Offer mit der bisher unbekannten SF-ID) die 404-Response automatisch.

Live-QA AK5a (gueltige SF-ID): HTTP 301 + Location auf kanonische Detail-URL. AK5b (unbekannte SF-ID): HTTP 404. Cutover-Path mit Host: www.solcom.de in DDEV nicht testbar (Router-Vhost), covered via PHPUnit GulpLegacyRedirectTest + post-deploy verify in Staging.