PRD · F-340 · P2503-112

project_offer-XML-Feeds — Freelancermap, freelance.de

draft

TL;DR. Drupal publiziert die heute vom alten ASP-System gelieferten XML-Feeds freelance.xml und freelancermap.xml ab dem Cutover (≤ 08.07.2026) selbst. Quelle ist node.type.project_offer (gespiegelt aus Salesforce, F-290/P2503-108). Auslieferung pro Vendor erfolgt über einen eigenen Drupal-Route + Twig-XML-Template; Filterung ausschließlich per field_sf_channel_freelance/_freelancermap + Status + Veröffentlichungs-Fenster — pflege erfolgt zu 100 % in Salesforce, ohne Drupal-Editorial. Code lebt im neuen Sub-Modul solcom_project_offer_feeds unter solcom_project_offer.

Problem Statement

Vertriebsmitarbeiterinnen und -mitarbeiter müssen ihre offenen Projektanfragen heute über zwei Wege gleichzeitig pflegen: einmal in Salesforce (Lead-System für die Auftragsausschreibung) und einmal manuell auf den Freelancer-Plattformen Freelancermap und freelance.de (Reichweite). Die heutige Brücke ist ein altes ASP-System unter www.solcom.de/asp/robots/*.xml, das beim Drupal-Cutover (≤ 08.07.2026) abgeschaltet wird. Ohne Drupal-seitigen Ersatz fällt die automatisierte Reichweite auf den beiden Plattformen ersatzlos weg und der manuelle Pflege­aufwand vervielfacht sich.

Komplikation: Der vorhandene Story-Hub und die Wiki-Page haben den Outbound-Feed historisch fälschlich an node.type.vacancy (Personio-Stellen für die SOLCOM-Karriereseite) gebunden. Vacancies und Project-Offers sind aber zwei verschiedene Bundles für zwei verschiedene Zielgruppen — Festanstellungen vs. Freelance-Aufträge. Marketplaces wie Freelancermap suchen Freelance-Aufträge, nicht Festanstellungen.

Solution

Drupal liefert nach dem Cutover unter /feeds/freelancermap.xml und /feeds/freelance.xml ein XML-Dokument im Format <openPositions> aus — kompatibel zur heutigen ASP-Spec, vendor-spezifisch parametrisiert. Inhalt sind alle publizierten Project-Offers, deren SF-Channel-Flag für den jeweiligen Vendor gesetzt ist und deren Veröffentlichungs-Fenster aktiv ist.

Die alten ASP-Pfade /asp/robots/freelance.xml und /asp/robots/freelancermap.xml liefern einen 301-Redirect auf die kanonischen Drupal-Pfade, sodass Vendoren, die ihre Konfiguration nicht sofort umstellen, keinen Tag Ausfall erleben. Die Vendoren erleben den Cutover idealerweise nur als wenige zusätzliche 301-Hops, dann pollen sie automatisch die neue URL.

Sämtliche Editorial-Pflege (Welcher Offer geht an welchen Vendor? Wann publiziert? Welche Beschreibung?) passiert ausschließlich in Salesforce und fließt über den bestehenden BH4SF→Drupal-Migrate-Sync ins project_offer-Bundle. Drupal-seitig gibt es kein neues Editorial-Feld und keine neue UI.

User Stories

  1. Als Vertriebsmitarbeiter möchte ich meine offene Projektanfrage nur in Salesforce pflegen, damit Freelancermap und freelance.de sie automatisch übernehmen, ohne dass ich auf der jeweiligen Plattform nachpflegen muss.
  2. Als Vertriebsmitarbeiter möchte ich pro Projektanfrage entscheiden können, ob sie auf Freelancermap, freelance.de oder beiden Plattformen erscheint — indem ich in Salesforce die entsprechenden Channel-Flags setze.
  3. Als Vertriebsmitarbeiter möchte ich, dass ein Offer mit Veröffentlichungs-Endedatum in der Vergangenheit automatisch aus beiden Feeds verschwindet, damit kein Freelancer auf eine ablaufene Anfrage reagiert.
  4. Als Vertriebsmitarbeiter möchte ich, dass ein Offer, dessen SC_PublicationStatus__c in Salesforce von „Online" auf einen anderen Status wechselt, beim nächsten Vendor-Poll nicht mehr im Feed erscheint.
  5. Als Freelancer möchte ich auf Freelancermap eine SOLCOM-Projektanfrage anklicken und auf einer aussagekräftigen Detail-Seite landen, damit ich entscheiden kann, ob das Projekt für mich passt.
  6. Als Freelancer möchte ich von der Vendor-Plattform aus direkt in den SOLCOM-Bewerbungs­flow (Minggo) springen, damit ich nicht erst einen Anruf oder eine Mail platzieren muss.
  7. Als Freelancermap-/freelance.de-Vendor-System möchte ich eine wohlgeformte XML mit <openPositions><position>…-Struktur empfangen, sodass mein bestehender Parser ohne Anpassung weiterläuft.
  8. Als Freelancermap-/freelance.de-Vendor-System möchte ich beim Polling der alten ASP-URL einen 301-Redirect auf die neue URL erhalten, damit mein Crawler-State sich automatisch aktualisiert.
  9. Als Site-Operator möchte ich, dass der Feed auch unter Last (50–300 Offers, mehrere Vendor-Polls pro Stunde) in < 100 ms warm ausgeliefert wird, sodass Vendor-Crawler nicht in Timeouts laufen.
  10. Als Site-Operator möchte ich, dass eine Salesforce-Pflege-Änderung spätestens innerhalb des Migrate-Intervalls plus einer Stunde im Vendor-Feed sichtbar ist, ohne dass ich Drupal-Caches manuell flushen muss.
  11. Als Site-Operator möchte ich, dass die Feed-URLs nicht von Suchmaschinen indiziert werden, damit Duplicate-Content gegenüber den project_offer-Detailseiten vermieden wird.
  12. Als Backend-Entwickler möchte ich die Feed-Logik in einem Sub-Modul kapseln, das ich unabhängig vom Haupt-Modul deaktivieren und in Tests isoliert ausführen kann.
  13. Als Backend-Entwickler möchte ich, dass vendor-spezifische Konstanten (Suffix, UTM-Parameter, Minggo-Workspace-ID) in einer Drupal-Config-Datei stehen, damit ich sie ohne Code-Deploy ändern kann.
  14. Als Backend-Entwickler möchte ich, dass die Apply-URL über die SF-ID des Offers automatisch in den Minggo-Deep-Link zusammengesetzt wird, ohne dass dieser Wert pro Offer manuell gepflegt werden muss.
  15. Als Backend-Entwickler möchte ich, dass die Description-URL aus Drupals kanonischer Route über entity.toUrl() zusammengebaut wird, damit künftige Pathauto-Änderungen den Feed nicht brechen.
  16. Als QA-Engineer möchte ich den Filter-Code des Builders mit einer Reihe von Fixture-Nodes (verschiedene status/pub_status/channel/publish-window-Kombinationen) testen, ohne einen vollständigen Salesforce-Sync zu fahren.
  17. Als QA-Engineer möchte ich gegen die Live-Routes HTTP-Requests absetzen können und prüfen, dass Content-Type, X-Robots-Tag und Cache-Tag-Headers korrekt gesetzt werden.
  18. Als QA-Engineer möchte ich prüfen, dass ein Request gegen /asp/robots/freelance.xml mit einem 301-Status und einem Location-Header auf /feeds/freelance.xml antwortet.

Implementation Decisions

Architektur-Map

Neues Sub-Modul solcom_project_offer_feeds unter dem bestehenden Modul solcom_project_offer — Pattern-Parität zu solcom_project_offer_alert. Das Sub-Modul kann unabhängig aktiviert/deaktiviert werden; die Migrate-Schicht im Haupt-Modul bleibt unberührt.

Das Sub-Modul enthält drei Code-Concerns plus zwei Twig-Templates plus eine Config-Datei:

  • Deep module — VendorFeedBuilder (Service): testbare Kern-Logik. Zwei Methoden:
    • getEligibleOffers(string $channelField): array<NodeInterface> — kapselt die Filter-Logik (Status, Pub-Status, Channel-Flag, Veröffentlichungs-Fenster).
    • buildXml(array $offers, VendorContext $ctx): array — gibt ein Drupal-Render-Array zurück. VendorContext ist ein Value-Object mit den vendor-spezifischen Konstanten.
  • Shallow module — VendorFeedController: dünner HTTP-Adapter. Eine Methode pro Vendor (freelancermap(), freelance()) — ruft Builder, setzt Response-Headers, packt das Render-Array in eine CacheableResponse.
  • Side module — LegacyRedirectSubscriber: kernel.request-EventSubscriber. Liefert 301-Responses für die zwei Legacy-Pfade. Eigene Klasse, eigener Test.
  • Twig-Templates: solcom-feed-freelancermap.xml.twig, solcom-feed-freelance.xml.twig. Beide rendern die <openPositions>-Struktur; die Vendor-Differenzen (Suffix, UTM-Doppel-URL) sind ausschließlich Template-Logik.
  • Config-Extension: neue Sektion feeds: in solcom_project_offer.settings.yml mit minggo_workspace_id, freelance.job_number_suffix, freelance.utm, freelancermap.utm.

Quelle & Filter

Quelle ist node.type.project_offer — explizit nicht vacancy. Frontend-Detail-Seite und Listing sind Teil von F-290/P2503-108 und müssen vor F-340 produktiv sein.

Per Vendor wird folgende Filterbedingung angewendet (Drupal-Entity-Query mit Subkonditionen):

FeldBedingungSource-of-Truth
status= 1Drupal-Standard-Publish-Flag
field_sf_pub_status= "Online"SF: SC_PublicationStatus__c
field_sf_channel_freelancermap bzw. _freelance= TRUESF: SC_Publishing_Channel_*__c
field_sf_publish_start≤ NOW()SF: hohr__Publishing_start_date__c
field_sf_publish_end≥ NOW() oder leerSF: hohr__Publishing_end_date_time__c

Sämtliche Filterung passiert in VendorFeedBuilder::getEligibleOffers() — der Controller weiß nicht, wie gefiltert wird.

URL- & Cache-Strategie

  • Kanonische Pfade: /feeds/freelancermap.xml, /feeds/freelance.xml.
  • Legacy-Pfade: /asp/robots/freelancermap.xml, /asp/robots/freelance.xml liefern 301-Redirect auf die kanonische URL. Handled vom LegacyRedirectSubscriber.
  • Cache-Strategie: Drupal-Page-Cache aktiviert. CacheableResponse mit Cache-Tags node_list:project_offer + einzelne node:<nid>-Tags der ausgespielten Offers. max-age = 3600 als Sicherheitsnetz für ausgefallene Tag-Invalidation.
  • SEO: Response-Header X-Robots-Tag: noindex, nofollow. Kein robots.txt-Eintrag.
  • Auth: keine. Endpoints sind öffentlich anonym, wie heute das ASP-System.

XML-Schema (gespiegelt aus Live-Samples in docs/context/)

Root <openPositions> → Wiederholungs-Element <position> mit 11 CDATA-Kindern. Alle Text-Felder werden mit <![CDATA[ … ]]> umschlossen.

<openPositions>
  <position>
    <jobNumber>{sf_id_oder_jobnr}{vendor_suffix?}</jobNumber>
    <jobTitle><![CDATA[ {title} ]]></jobTitle>
    <jobDescription><![CDATA[ {long_description_html} ]]></jobDescription>
    <jobRequirements><![CDATA[ {requirements_html} ]]></jobRequirements>
    <jobAddition><![CDATA[ {utilisation_oder_call_to_action} ]]></jobAddition>
    <jobStart><![CDATA[ {dd.mm.yyyy aus field_sf_estimated_start} ]]></jobStart>
    <jobDuration><![CDATA[ {freier String aus field_sf_duration} ]]></jobDuration>
    <jobPlace><![CDATA[ {field_sf_city|fallback "keine Angabe"} ]]></jobPlace>
    <jobPlaceISOCode><![CDATA[ {field_geo:country_iso?} ]]></jobPlaceISOCode>
    <jobDescriptionURL><![CDATA[ {entity.toUrl(absolute=true)} ]]>
      {nur freelance: <![CDATA[ {entity.toUrl} + ?utm_source=…&utm_medium=…&utm_campaign=… ]]>}
    </jobDescriptionURL>
    <jobApplicationURL><![CDATA[ https://solcom.minggo.io/{minggo_workspace_id}/apply/{field_sf_id} ]]></jobApplicationURL>
  </position>
  …
</openPositions>

Pflicht-Feldmapping zwischen project_offer-Feldern und XML-Tags wird im Implementations-Plan finalisiert (Field-by-Field-Spec).

Vendor-Config

Drupal-Config in solcom_project_offer.settings.yml, Sektion feeds::

feeds:
  minggo_workspace_id: 'da891d49-443e-4269-a378-e5fe7d2d4649'
  freelance:
    job_number_suffix: '-298'
    utm:
      utm_source: 'freelance.de'
      utm_medium: 'social'
      utm_campaign: 'Jobposting'
  freelancermap:
    utm: {}

Die UTM-Werte für freelance werden bei Implementierung mit dem Vendor abgestimmt (heutige Werte utm_source=freelancercheck.de sind Drift im Legacy-ASP-Feed und werden nicht übernommen — siehe Dependencies).

Apply-URL-Bildung

Pro Position wird der Minggo-Deep-Link aus zwei Werten zusammengesetzt:

  • minggo_workspace_id aus Drupal-Config (konstant für SOLCOM).
  • field_sf_id am Node (= Salesforce-Record-ID des Offers).

Der Twig baut: https://solcom.minggo.io/{minggo_workspace_id}/apply/{field_sf_id}. Wenn field_sf_id leer ist (Drift-Fall), wird die Position aus dem Feed ausgespielt ohne <jobApplicationURL>-Tag — der Feed bleibt wohlgeformt, der Vendor zeigt den Klick-Button passend nicht an.

Description-URL-Bildung

Drupals kanonische Detail-URL pro Node via node.toUrl('canonical', ['absolute' => TRUE])->toString(). Damit ist die Description-URL automatisch synchron mit dem aktuell gültigen Pathauto-Pattern und überlebt zukünftige IA-Umzüge transparent.

Für freelance-Vendor wird zusätzlich ein zweiter CDATA-Block innerhalb desselben <jobDescriptionURL>-Tags ausgespielt, der die UTM-Parameter trägt — Format gespiegelt aus dem Live-Sample.

Testing Decisions

Tests prüfen externes Verhalten, nicht Implementations-Details. Konkret heißt das: Filter-Tests setzen Fixture-Nodes und prüfen das Ergebnis (welche Nodes kommen durch), nicht den Query-Aufbau. Route-Tests setzen HTTP-Requests ab und prüfen Response-Status, Headers und das XML als Black-Box — nicht den Controller-Service-Wiring.

Pflicht-Tests

  1. VendorFeedBuilderTest (Kernel). Drei bis fünf Fixture-Nodes mit unterschiedlichen Kombinationen aus status, field_sf_pub_status, field_sf_channel_*, field_sf_publish_start/_end. Assertion: getEligibleOffers('field_sf_channel_freelancermap') liefert exakt die erwartete Teilmenge. Tests pro Filter-Achse einen Positiv- und einen Negativ-Case.
  2. VendorFeedRouteTest (Functional). HTTP-GET gegen /feeds/freelancermap.xml und /feeds/freelance.xml. Assertions: HTTP-200, Content-Type: application/xml, X-Robots-Tag: noindex, nofollow, Cache-Tag-Headers enthalten node_list:project_offer, Response-Body ist valides XML mit <openPositions>-Root. Smoke-Assertion: Anzahl <position>-Elemente entspricht der Anzahl publizierter Fixture-Nodes mit Channel-Flag.
  3. LegacyRedirectSubscriberTest (Functional). HTTP-GET ohne Follow gegen /asp/robots/freelance.xml und /asp/robots/freelancermap.xml. Assertions: HTTP-301, Location-Header zeigt auf den kanonischen /feeds/*-Pfad.

Prior Art

Pattern-Vorbild: solcom_project_offer_alert-Tests (Schwester-Sub-Modul). Migrate-Test-Vorbild für die Filter-Logik: solcom_vacancy/tests/ (Kernel-Tests gegen migrate_plus-Pipeline). Funktional-Test-Vorbild: solcom_editorial/tests/Functional/.

Optional (nice-to-have, nicht im DoD)

Schema-Snapshot-Test gegen die in docs/context/freelance.xml und docs/context/freelancermap.xml abgelegten Live-Samples — DOMDocument-basiert, prüft Tag-Namen + CDATA-Wrapping in der erwarteten Reihenfolge. Sinnvoll bei späterer Schema-Drift-Diskussion, nicht MVP-blockierend.

Out of Scope

  • Gulp- und Randstad-Feeds — leben unter P2503-113 / F-360 in einer eigenen Story (eigene Vendor-Spec, eigene Config-Block, eigene Twig-Templates; Pattern wird hier etabliert und in F-360 nur wiederverwendet).
  • Editorial-Drupal-UI für „dieses Offer aus dem Feed ausschließen" — Pflege passiert ausschließlich in Salesforce.
  • Push-Mechanik (Webhook, SFTP-Upload) — die Vendoren pollen, wir liefern auf Anfrage. Wenn ein Vendor künftig Push verlangt, eigene Story.
  • Reverse-Tracking der Bewerbungen, die über den Minggo-Link reinkommen — gehört in den Bewerbungs-Flow (F-330 / Minggo-Integration).
  • i18n — der Feed liefert ausschließlich Deutsch, weil project_offer heute DE-only ist.
  • Pagination des Feeds — die Vendoren erwarten den Vollbestand in einem Response, nicht Pages.
  • Pathauto-Pattern-Update auf den neuen IA-Pfad /fuer-freiberufler/projektliste/<slug> — gehört zu F-290/P2503-108 oder zu einer eigenen URL-Kanon-Story (siehe Dependencies).
  • ASP-Detail-URL-Redirect für Legacy-Vendor-Backlinks der Form /asp/robots/detail.aspx?id=<id> — wird im Cutover-Plan adressiert, nicht in F-340 (siehe Dependencies).
  • Empty-Feed-Behavior — Default: wohlgeformte <openPositions/>-Response mit Status 200. Kein 404, kein 503. Wenn der Vendor das anders erwartet, eigene Story.

Dependencies / offene Fragen

  • F-290 / P2503-108 (project_offer-Sync) muss vor F-340 produktiv sein. Ohne Migrate-Sync gibt es keine project_offer-Nodes, also nichts zu publizieren.
  • Pathauto-Pattern-Update: pathauto.pattern.project_offer_content.yml zeigt heute auf /projektpartnerportal/[node:title]; Memory + IA-Vorgabe (siehe docs/context/0002-sitemap.html) verlangen /fuer-freiberufler/projektliste/[node:title]. F-340 verwendet entity.toUrl() und folgt damit dem dann gültigen Pattern; aber für die XML-Description-URL muss der neue Pfad spätestens beim Cutover live sein.
  • Drupal-Cutover-Plan: www.solcom.de muss am Go-Live-Tag (≤ 08.07.2026) auf das Drupal-System zeigen, damit die /feeds/*- und /asp/robots/*-Routes überhaupt vom Drupal beantwortet werden.
  • Legacy-Detail-URL-Redirect-Strategie (Advisor-Befund): Vendor-Anzeigen, die heute bereits live sind, zeigen auf solcom.de/asp/robots/detail.aspx?id=<id>&mode=FREELANCERCHECK. Wenn der ASP-301-Redirect nur die Feed-Pfade abdeckt, brechen alte Vendor-Backlinks beim Cutover. Klärungs-Bedarf: catch-all-Redirect von /asp/robots/detail.aspx?id=* → Drupal-Detail-Seite (Lookup über field_sf_id oder über jobNumber) — oder Stakeholder-Akzeptanz, dass alte Vendor-Backlinks ablaufen.
  • Echte Channel-Flag-Pflege in SF: Migrate-Mapping ist da, aber wenn das Salesforce-Datenmodell SC_Publishing_Channel_Freelancermap__c und SC_Publishing_Channel_Freelance__c nicht produktiv pflegt, ist der Feed jeden Tag leer. Test mit echten SF-Daten im DDEV-Staging vor Cutover.
  • Korrekte UTM-Werte für freelance.de: Heutige Werte (utm_source=freelancercheck.de) sind Drift im Legacy-ASP-Feed und werden nicht übernommen. Korrekte Werte werden bei Implementierung mit dem Vendor abgestimmt; Vorab-Vorschlag siehe Config-Block oben.
  • Drupal-seitige Pflege von field_sf_estimated_start als dd.mm.yyyy: Das DateTime-Field in Drupal liefert ISO-8601; das Twig formatiert um. Field-Spec im Implementations-Plan finalisieren.

Further Notes

Doku-Korrekturen als Folge dieses Refinements:

  • docs/stories/F-340-asp-xml-feeds-freelancermap-freelance.html wird neu geschrieben (Quelle vacancyproject_offer, Modul-Heimat, AKs gefüllt, URL-Strategie, Cache-Strategie).
  • docs/wiki/freelancer-feeds.html wird in Section „Feed-Übersicht" und „Operative Kurzfassung" korrigiert: F-340/F-360 basieren auf node.type.project_offer, nicht auf node.type.vacancy.
  • docs/adr/0032-project-offer-sync-hygiene.html hat URL-Kanon-Drift gegen Memory project_projektliste_canonical_url — eigener Update-PR, nicht in F-340.
  • config/sync/pathauto.pattern.project_offer_content.yml hat den alten Pfad — gehört zu F-290 oder eigener URL-Kanon-Story.

Pattern-Spiegelung zu ADR-0035: Die Personio-Entscheidung (inbound XML statt REST) und die F-340-Entscheidung (outbound XML statt Push-API) folgen demselben Prinzip — XML-Feeds sind das Drupal-idiomatische Pattern für strukturierte Vendor-Integrationen mit stabilem Schema und ohne Service-Identity-Anforderung. Die Symmetrie ist absichtlich.

Forcing-Function: Der Drupal-Cutover am 08.07.2026 ist hart. F-340 muss spätestens drei Wochen davor produktiv-testbar sein, weil der Vendor-Poll-Zyklus (täglich) ein erstes Sample-Window für Vendor-Validation braucht.

Performance-Annahme: 50–300 publizierte Project-Offers gleichzeitig. Bei mehr (z. B. > 1000) ist die Annahme „ein Response = Vollbestand" zu überdenken (Pagination, Streaming-Response). Aktuell nicht relevant.