Product Requirements Document · solcom · P2503-108 · F-290 · revised 2026-05-14

project_offer-Sync (BH4SF) + PPP-Frontend — End-to-End-PRD für F-290

📌 Superseded inline (2026-05-15) — die in diesem PRD genannten URL-Pfade /projektportal und /projektportal/projektangebote/[node:title] sind durch die jüngere ADR-Iteration überholt. Kanonische URL für Listing-View und Pathauto-Pattern ist /projektpartnerportal (View und Detail-Nodes leben unter demselben Pfad-Prefix). Quellen: ADR-0031 und ADR-0032. PRD-Body bleibt unverändert als Historie; Code-State folgt der ADR.
TL;DR — Drupal spiegelt SOLCOM-FCSB-Salesforce-Project-Offers (BH4SF, TR1__Job__c mit RecordType = Publication) stündlich via migrate_plus in Drupal-Nodes vom Bundle project_offer. Lifecycle ist SF-gesteuert (node.status aus SC_PublicationStatus__c == "Online"); das Publishing-Window (publish_start <= now < publish_end) wird render-time im Views-Filter und im Route-Access-Check ausgewertet, nicht beim Migrate-Run. Beim Publish schreibt Drupal die kanonische PPP-URL zurück nach hohr__Job_Application_URL__c, beim Unpublish wird das Feld geleert. Hard-Delete bei SF-Verschwinden via Per-Run-Sweep (`MigrateEvents::POST_IMPORT`-EventSubscriber). Frontend (Layer A + B beide MVP-Pflicht) liefert Detail-Canvas-Template project_offer.full, Card-Template project_offer.card, Drupal-View project_offers_listing mit BEF-Filtern (Branche, Skills, Remote-Anteil, Ort/Radius, Vollzeit/Teilzeit), Canvas-Page /projektportal, Custom-Block PPP-Cross-Sell, Skill-Badges, Merkliste (anon LocalStorage), Matching-Rate, Projekt-Alert (E-Mail-Subscription), JSON-LD nach schema.org/JobPosting. Scope: Inbound + Outbound + Frontend für BH4SF-Quelle. User-Linking via field_sf_contact_id (Phase 2, F-400-abhängig) bleibt out of scope. MVP-Termin: 2026-07-08. Domain-Rename: Diese Revision führt den Rename sf_publicationproject_offer ein (Bundle, Modul, Vocabulary industry, 21 Felder, Migration-Map-Tabelle, alle Klassen-Namen) — entstanden aus dem Grilling 2026-05-14 (Live-Site solcom.de nennt es „Projektangebote"; Publication ist BH4SF-RecordType-Implementierungs-Detail, kein Domain-Begriff).

Problem Statement

SOLCOM-Recruiter:innen und Account-Manager:innen pflegen offene Projektangebote vollständig in Salesforce (FCSB-Org, BH4SF-Layer auf dem TR1__Job__c-Objekt mit RecordType Publication). Das geplante Projektpartnerportal (PPP) auf solcom.de hat aktuell keinen automatisierten Zugang zu diesem Bestand — Freiberufler:innen müssten entweder einen SF-Login bekommen (nicht akzeptabel, kein Self-Service) oder SOLCOM müsste Projektangebote redundant in Drupal pflegen (Drift, Doppel-Aufwand, Lifecycle-Inkonsistenz).

Zusätzlich braucht das Frontend des PPP eine eigenständige Render-Schicht: eine Detail-Page mit allen Anforderungs-Inhalten, eine Listing-View mit Filtern entlang der Such-Achsen (Branche, Skills, Remote-Anteil, Ort/Suchradius, Vollzeit/Teilzeit), eine Card-Repräsentation, Merklisten- und Alerting-Mechanik für wiederkehrende Besucher:innen, und SEO-Discovery via JSON-LD (analog zur Personio-Vacancy-Strecke).

Stimme aus Vertrieb / Recruiting

„Unsere offenen Projekt-Anforderungen leben in Salesforce — als BH4SF-Publications, gepflegt vom zuständigen Account-Team mit Publishing-Window und Status 'Online'. Wir wollen, dass Freiberufler:innen diese Projektangebote im PPP browsen können, ohne SF-Konto, ohne Doppel-Pflege. Wenn wir ein Angebot offline nehmen oder das Publishing-End-Datum überschreitet, soll es im PPP verschwinden. Und wenn Drupal die finale PPP-URL hat, muss diese in SF zurückgeschrieben werden — damit unsere anderen Tools (Reporting, Outreach-Anzeigen, Vertriebsdokumentation) die gleiche URL kennen."

Stimme aus dem Grilling 2026-05-14 (Naming-Korrektur)

„'SF Publication???' — das sind doch Projekte! Schau dir solcom.de/de/projektportal an: da heißen die durchgängig 'Projektangebote'. 'Publication' ist BH4SF-Vokabular, kein Domain-Begriff für SOLCOM. Auch die externen Plattformen (Gulp, Freelance.de, Freelancermap) sind Outbound-Distributions-Ziele, nicht Inbound-Quellen — die Project-Offers werden für diese Dienste bereitgestellt, nicht andersherum."

Solution

Eigenes Drupal-Modul solcom_project_offer unter web/modules/custom/. Hybrid-Architektur mit drei Schichten, die einen gemeinsamen Bundle project_offer bedienen, plus Frontend-Schicht in byte_theme:

Sobald eine SF-Project-Offer das Publishing-Window erreicht und SC_PublicationStatus__c = "Online" ist, erscheint sie spätestens nach einer Stunde im PPP. Drupal schreibt die PPP-URL zurück nach SF. SF bleibt Source-of-Truth — Drupal-Editor:innen können project_offer-Nodes nicht manuell editieren oder anlegen (Vacancy-Editorial-Ausschluss-Pattern, ADR-0026).

Goals (V1)

Backend Frontend Layer A Frontend Layer B — alle drei MVP-Pflicht bis 2026-07-08.

  1. Backend Content-Type project_offer in config/sync/ definiert (21 Storage-Fields gemäß Field-Naming-Konvention + node.title + node.status, drush cim-reproducible).
  2. Backend Vocabulary industry mit 25 Terms (DE-Labels + field_sf_value), per hook_install pre-seeded.
  3. Backend Modul solcom_project_offer im Default-Sync aktiviert; dev_split handhabt nichts zusätzliches.
  4. Backend Migration project_offer läuft stündlich (Cron 3600s), idempotent über SF-Id, mit nextRecordsUrl-Paging und RecordType-Filter.
  5. Backend OAuth-2.0-Refresh-Token-Flow funktioniert gegen FCSB-Sandbox-Org; Token wird automatisch refresht; Credentials nicht im Repo ($config-Override aus Env-Vars).
  6. Backend Hard-Delete-Lifecycle: SF-verschwundene Records → Drupal-Node-Delete nach max. 1 Stunde. Empty-Feed-Schutz: 0 Records → Sweep skip + Watchdog-Warning.
  7. Backend Outbound-PATCH funktioniert: Publish-Transition → hohr__Job_Application_URL__c gesetzt; Unpublish-Transition → Feld geleert.
  8. Backend Outbound-Trigger-Guard verhindert Queue-Flooding während Migrate-Runs (nur Status-Transitionen, nicht jeder Save).
  9. Backend Industry-Lookup: Custom Process Plugin matcht SF-Value gegen field_sf_value, Auto-Create-on-Miss mit Watchdog-Warning.
  10. Backend Rich-Text-Sanitize: 4 Rich-Text-Felder durchlaufen SalesforceRichTextSanitizer-Service (DOMDocument-Whitelist), Force.com-Müll (<font>, <o:p>, MS-Word-Tags, inline-Styles) wird entfernt vor DB-Write.
  11. Backend Lifecycle-Window-Auswertung render-time: node.status reflektiert nur SC_PublicationStatus__c == "Online". Window-Check publish_start <= now < publish_end als Views-Filter (für Listings) + ProjectOfferWindowAccess (Route-Access-Check für Direkt-URL-Zugriff).
  12. Backend Domain-Rename-Migration durchgezogen: Bundle sf_publicationproject_offer, Modul solcom_sf_publicationsolcom_project_offer, Vocabulary sf_industryindustry, 21 Field-Storage-Migrationen nach Konvention (siehe Field-Naming), Migration-Map-Tabelle-Rename via hook_update_N. Update-Hooks idempotent.
  13. Backend ADR-0029 amended: dokumentiert die Hybrid-Architektur unter dem neuen Naming und enthält eine explizite Rejected-Sektion „drupal/salesforce Suite" mit den konkreten Kriterien (Composer-Footprint, ext-soap-Pflicht, ADR-0013-Bruch, 58-Commits-Switching-Cost).
  14. Backend Tests pro Custom-Modul-Komponente (siehe Testing Decisions): Unit (Sanitizer), Kernel (Plugins, EventSubscriber, OutboundWorker, WindowAccess, JsonLdBuilder), Functional (End-to-End-Migration).
  15. Frontend A View-Mode-Configs (card, full, teaser) + Pathauto-Pattern /projektportal/projektangebote/[node:title] in config/sync/.
  16. Frontend A Canvas-Content-Template project_offer.full nach Figma-Frame 1019-78888 (Detail-View). Header, Long-Description, Internal-Description, Requirements, Nice-to-have, Project-Meta (Auslastung, Wunschstart, Spätester Start, Dauer, Einsatzort, Branche), VIS-Request-Nummer als Referenz, Apply-CTA (Minggo-iFrame-Embed via F-330).
  17. Frontend A Canvas-Content-Template project_offer.card nach Figma-Frame 985-50077 (Listing-Row). Baut auf byte_theme/card-SDC (prop-driven Variante der Card-Familie, Vacancy-Parität-Schablone).
  18. Frontend A Drupal-View project_offers_listing mit Better-Exposed-Filters: 5 Filter (Branche, Auslastung, Einsatzort, Vollzeit/Teilzeit-Flag (abgeleitet aus Auslastung-Range), Suchbegriff-Volltext). Sortierung default nach last_modified DESC.
  19. Frontend A Canvas-Page /projektportal als Listing-Page-Layout: View-Embed, Cross-Sell-Block, SEO-Footer. Canvas-Region-Composition gemäß Figma-Frame.
  20. Frontend A Custom-Block-Plugin „PPP-Cross-Sell" (config-driven, 3 verlinkte Karten zu Sister-Pages: Karriere, Sicher im Projekt, Projekt-Alert).
  21. Frontend A JSON-LD nach schema.org/JobPosting via ProjectOfferJsonLdBuilder-Service + hook_node_view-Adapter (analog Vacancy-Pattern ADR-0013-inline). Gibt title, description, datePosted (= field_publish_start), validThrough (= field_publish_end), employmentType: "CONTRACTOR", jobLocation (aus field_city), industry (aus DE-Label), identifier (VIS-Request-No) aus.
  22. Frontend A Figma-Sync-MAPPING aktualisiert (docs/design/MAPPING.html): Frames 1019-78888 + 985-50077 auf project_offer.full/.card gemappt, Status matched.
  23. Frontend A Pre-Build-Gate: Live-Describe gegen FCSB-Sandbox auf zusätzliche Felder für Layer-B-Filter (Skills, Remote-Anteil). Klärung, ob Custom-Field am TR1__Job__c existiert oder Mapping via existierendem Long-Description-Tag erfolgt.
  24. Frontend B Skill-Badges auf Card + Skills-Filter (BEF, Mehrfach-Auswahl). Datenquelle: neues Vocabulary skill (free-tagging im V1) oder Custom-Field am SF-Side, abhängig vom Pre-Build-Gate.
  25. Frontend B Remote-Anteil-Slider in Card (0/25/50/75/100 %) + Range-Filter im BEF.
  26. Frontend B Suchradius-Filter: PLZ-Eingabe + Radius-Slider (10/25/50/100 km). Geo-Distance via geocoder + geofield-Drupal-Module. Sub-Task hat eigenen Pre-Build-Gate (Geocoder-Provider-Wahl: OSM-Nominatim für DSGVO-Konformität bevorzugt).
  27. Frontend B Merkliste: anonymer LocalStorage-basierter Mechanism (kein User-Account nötig), max. 5 Projekte, kein Server-Roundtrip. UI: Heart-Icon auf Card + Detail-Page, Merkliste-Overview-Page /projektportal/merkliste.
  28. Frontend B Matching-Rate auf Card (V1 als faked Placeholder-Logik: zufällig zwischen 60–95 % pro Session, sticky via LocalStorage). Wirkung: visueller Engagement-Treiber. Echte Matching-Logik kommt Phase 2 mit User-Profile-Pull (F-400).
  29. Frontend B Projekt-Alert: E-Mail-Subscription-Formular auf Listing-Page und Detail-Page. Nutzer:in gibt E-Mail + Filter-Kombination ein; Drupal speichert Subscription, sendet täglichen oder wöchentlichen Digest-Mail bei neuen Project-Offers, die zur Filter-Kombination passen. Implementation via solcom_project_offer_alert-Submodul mit eigener Entity + Cron-Hook.
  30. Backend ADR-0031 amended (Layer-A/B-Trennung aufgelöst — alle Frontend-Items in MVP integriert; Naming-Update auf project_offer).

User Stories

  1. Als SF-Account-Manager:in möchte ich ein Projektangebot in BH4SF freigeben (SC_PublicationStatus__c = "Online" innerhalb Publishing-Window), damit es spätestens nach 60 Minuten im PPP erscheint — ohne dass irgendwer manuell in Drupal etwas pflegt.
  2. Als Freiberufler:in möchte ich offene SOLCOM-Projektangebote im PPP browsen ohne SF-Login, damit die Hürde zum Reinschauen minimal bleibt.
  3. Als Freiberufler:in möchte ich Branche, Auslastung, Wunschstart, Spätester Start, Dauer, Einsatzort, Must-haves und Nice-to-haves als deutsche Strings auf der Detail-Page sehen, damit ich ohne SF-Vokabular sofort verstehe, worum es geht.
  4. Als Account-Manager:in möchte ich, dass die im PPP veröffentlichte URL eines Projektangebots automatisch nach SF zurückgeschrieben wird (hohr__Job_Application_URL__c), damit Reporting / Outreach / Anzeigen-Workflows die kanonische URL ohne weiteren Pflegeaufwand kennen.
  5. Als Account-Manager:in möchte ich, dass beim Unpublish eines Projektangebots (Status nicht mehr „Online" ODER Datum außerhalb des Publishing-Windows) das hohr__Job_Application_URL__c-Feld in SF geleert wird, damit veraltete URL-Referenzen nicht in SF weiterleben.
  6. Als SF-Admin möchte ich, dass beim Hard-Delete eines Projektangebots in SF das entsprechende Drupal-Node nach max. 1 Stunde verschwindet (Per-Run-Sweep), damit kein verwaister Drupal-Node existiert ohne korrespondierenden SF-Record.
  7. Als Drupal-Editor:in möchte ich project_offer-Nodes nicht manuell editieren oder anlegen können — SF ist Source-of-Truth, jede Drupal-seitige Änderung würde beim nächsten Import überschrieben (konsistent mit ADR-0026-Vacancy-Editorial-Ausschluss).
  8. Als Freiberufler:in möchte ich die Branche eines Projektangebots als deutsches Label sehen („Bankwesen" statt „Banking"), damit Suche und Filter natürlichsprachig funktionieren.
  9. Als Freiberufler:in möchte ich Rich-Text-Beschreibungen sauber formatiert sehen (ohne Force.com-<font>-Tags, ohne MS-Word-<o:p>-Artefakte, ohne inline-Styles), damit Inhalte lesbar bleiben und SEO-tauglich sind.
  10. Als Freiberufler:in möchte ich die VIS-Request-Number sehen können, damit ich bei Rückfragen einen eindeutigen Referenz-Identifier nennen kann.
  11. Als System-Operator:in möchte ich, dass bei kurzfristigen SF-Outages die letzten erfolgreichen Projektangebote weiter im PPP sichtbar bleiben (Drupal als Read-Buffer), damit keine PPP-Downtime entsteht.
  12. Als System-Operator:in möchte ich, dass die SF-API-Credentials nicht im Git-Repository liegen ($config-Override aus Env-Vars SF_FCSB_CLIENT_ID/_SECRET/_REFRESH_TOKEN), damit der Sicherheits-Standard eingehalten wird.
  13. Als System-Operator:in möchte ich, dass der Reconciliation-Sweep nicht den gesamten PPP-Bestand löscht, wenn die SF-API einen leeren Response liefert (Empty-Feed-Schutz analog VacancyHardDeleteSubscriber), damit Sandbox-Ausfälle nicht zu Daten-Verlust führen.
  14. Als Developer:in möchte ich, dass das Field-Mapping (21 Felder + 25 Picklist-Werte) versioniert im Repo liegt (Context-Snapshot 0008 + Migration-YAML in config/sync/), damit SF-Schema-Drift sichtbar und korrigierbar ist.
  15. Als Editor:in möchte ich, dass auto-erzeugte Industry-Terms (bei SF-Picklist-Drift) mit einer Watchdog-Warning auffallen, damit ich proaktiv die deutsche Übersetzung nachpflegen kann.
  16. Als Developer:in möchte ich, dass alle SF-Schema-Annahmen vor der ersten Code-Zeile per Live-Describe gegen die FCSB-Sandbox verifiziert werden (P2503-127, P2503-146), damit Field-Name-Drifts nicht erst im Pipeline-Test auffallen.
  17. Als Developer:in möchte ich, dass die Outbound-Pipeline ereignisbasiert auf Publish-Status-Transitionen feuert (nicht auf jedem Save), damit die Queue nicht durch die Migrate-internen Saves geflutet wird.
  18. Als Developer:in möchte ich, dass Stand-of-the-art Drupal-Konventionen eingehalten werden (migrate_plus, ADR-0013, Plugin-Trio + EventSubscriber + DI-Service), damit das Modul für andere Entwickler:innen vertraut anfasst — analog zum bestehenden solcom_vacancy.
  19. Als Freiberufler:in möchte ich, dass ein Projektangebot, dessen Publishing-Window abgelaufen ist (publish_end < now), sofort aus Listings verschwindet — nicht erst nach dem nächsten Cron-Run, damit ich nicht auf bereits geschlossene Angebote klicke.
  20. Als Freiberufler:in möchte ich, dass ein Direkt-URL-Zugriff (Bookmark, Google-Snippet) auf ein abgelaufenes Projektangebot eine sinnvolle Antwort liefert (404 oder Redirect zur Listing-Page), damit ich nicht auf falsche Erwartungen festgenagelt werde.
  21. Als Freiberufler:in möchte ich die Projektangebote nach Branche, Auslastung, Einsatzort, Vollzeit/Teilzeit und Suchbegriff filtern können, damit ich relevante Angebote schnell finde.
  22. Als Freiberufler:in möchte ich, dass das Projektangebot-Listing eine SOLCOM-typische Card-Optik hat (Bubble-Notch, klare Hierarchie), damit ich Vertrauen in die Anzeige habe.
  23. Als Suchmaschine (Google for Jobs, Bing) möchte ich, dass jedes Projektangebot strukturierte Daten nach schema.org/JobPosting liefert, damit es in den entsprechenden Search-Snippets korrekt indexiert wird.
  24. Als Freiberufler:in möchte ich Skills, die im Projektangebot gefordert sind, als Badges auf Card und Detail-Page sehen, damit ich schnell scanne, ob mein Profil matched.
  25. Als Freiberufler:in möchte ich nach Skills filtern (Mehrfach-Auswahl), damit ich nur Projekte sehe, die zu meinem Tech-Stack passen.
  26. Als Freiberufler:in möchte ich nach Remote-Anteil filtern (z. B. „mind. 50 % remote"), damit ich Vorlieben für hybrides Arbeiten direkt im Listing umsetzen kann.
  27. Als Freiberufler:in möchte ich nach Suchradius um eine PLZ filtern (z. B. „50 km um 70499 Stuttgart"), damit Angebote in pendel-tauglicher Distanz im Listing landen.
  28. Als Freiberufler:in möchte ich Projektangebote für später vormerken können (Merkliste, anonym), damit ich beim nächsten Besuch direkt darauf zurückkomme — ohne mich registrieren zu müssen.
  29. Als Freiberufler:in möchte ich eine visuell prominente Matching-Rate auf Card und Detail-Page sehen (z. B. 87 % Match), damit ich Engagement zum Angebot aufbaue. (V1 als Placeholder; echte Logik kommt mit User-Profile-Pull in Phase 2.)
  30. Als Freiberufler:in möchte ich mich für einen Projekt-Alert mit Filter-Kombination eintragen können (E-Mail + Branche + Skills + Radius), damit ich neue passende Angebote per Mail bekomme, ohne aktiv im PPP zu suchen.
  31. Als SOLCOM-Marketing möchte ich, dass von der Listing-Page Cross-Sell-Karten zu thematisch verwandten Pages (Karriere, Sicher im Projekt, Projekt-Alert) führen, damit Besucher:innen den weiteren SOLCOM-Service-Kosmos kennenlernen.
  32. Als SOLCOM-Geschäftsführung möchte ich, dass das Naming im Code, Doku und Editor-UI dem Marketing-Begriff der Live-Site entspricht („Projektangebot", nicht „Publication"), damit die interne Sprach-Konsistenz hoch bleibt.

Implementation Decisions

Domain-Naming-Migration D-1 + D-9 + D-12

Der Bundle/Modul/Vocabulary-Name wird von sf_publication/solcom_sf_publication/sf_industry auf project_offer/solcom_project_offer/industry umgezogen. Domain-Wort aus der Live-Site solcom.de/de/projektportal übernommen (Nav, H1, CTA: durchgängig „Projektangebote"). „Publication" wird zum BH4SF-RecordType-Implementierungs-Detail demoted — taucht nur noch in SOQL-Query und Excel-Mapping-Doku auf, nicht im Drupal-Code oder Editor-UI.

Migrations-Sequenz (atomarer Refactor-PR, ≤ 1 Story-Sub-Task):

  1. Modul-Directory-Rename web/modules/custom/solcom_sf_publication/solcom_project_offer/; *.info.yml + Namespace-Update; composer.json autoload-PSR-4-Map.
  2. 21 Field-Storage-Migrationen via hook_update_N: pro Feld create-newcopy-datauninstall-old. Reihenfolge nach Risk (Reference-Fields zuletzt).
  3. Migration-Map-Tabelle-Rename: ALTER TABLE migrate_map_sf_publication RENAME TO migrate_map_project_offer; via hook_update_N. Idempotent über Table-Exists-Check.
  4. Config-Sync-Files in config/sync/: 38 YAMLs renamed + Inhalt aktualisiert; Re-Export via drush cex, Verify via drush config:status.
  5. Canvas-Templates: canvas.content_template.node.sf_publication.{card,full}.ymlnode.project_offer.{card,full}.yml.
  6. View: views.view.publications_listing.ymlviews.view.project_offers_listing.yml; alle Filter-Identifier mit.
  7. Block-Plugin-IDs: solcom_sf_publication_pp_cross_sellsolcom_project_offer_pp_cross_sell.
  8. Twig-Templates in byte_theme/templates/ und SDC-References: Bundle-Refs aktualisiert.
  9. Outbound-Live-Risiko (temporär stale SF-URLs): Während des Refactor-Sprints (~1 Tag DDEV-Local, kein Live-Deploy mid-flight) bewusst akzeptiert. Direkt nach Re-Import läuft drush solcom-project-offer:push-urls einmal manuell, um alle gesetzten URLs neu zu schreiben.
  10. Jira-Sub-Task-Titel-Updates (P2503-128 bis P2503-145): via acli jira workitem edit in einem Sweep nach Merge des Refactor-PR.

Rejected: „Nur User-facing Strings auf Projektangebot, machine_names bleiben" (Option B im Grilling) — wegen Inkonsistenz zwischen Bundle-Label und machine_name, die Site-Builder und Developer dauerhaft mental übersetzen müssten. „Refactor als Post-MVP-Story" (Option C) — wegen Drift-Risk und „deprioritisiert und vergessen"-Pattern.

Field-Naming-Konvention D-10

Vacancy-Pattern: Source-Prefix nur für External-Identifier, Content-Felder prefix-frei. node.title ersetzt eigenes Title-Field; node.status reflektiert SF-Online-Bit.

DrupalSF-APITypBemerkung
node.titleTR1__Job_Title__cDrupal-Built-inStellenbezeichnung. Kein eigenes Feld.
node.statusSC_PublicationStatus__cDrupal-Built-inBerechnet via static_map: "Online" → 1, default → 0.
field_sf_idIdText(18), uniqueExternal-Key, Map-Key für migrate_map_project_offer.
field_descriptionhohr__Long_description__cLong Text formattedSanitize-Pipeline. Aufgaben.
field_conditionshohr__Internal_description__cLong Text formattedSanitize. Rahmenbedingungen.
field_requirementshohr__Requirements_description__cLong Text formattedSanitize. Must-haves.
field_nice_to_haveTR1__Experience_Requirements__cLong Text formattedSanitize.
field_utilisationSC_Utilisation__cText(30)Auslastung.
field_estimated_startTR1__Estimated_Start_Date__cDateWunschstart.
field_latest_startSC_LatestStart__cDateSpätester Start.
field_durationSC_Duration__cText(50)Dauer.
field_cityTR1__City__cText(60)Einsatzort.
field_industryTR1__Sector1__cEntity Reference → industryCustom Process Plugin Lookup.
field_publish_starthohr__Publishing_start_date_time__cDatetimeWindow-Anfang.
field_publish_endhohr__Publishing_end_date_time__cDatetimeWindow-Ende.
field_channel_gulpSC_Publishing_Channel_Gulp__cBooleanOutbound-Distributions-Channel.
field_channel_freelanceSC_Publishing_Channel_Freelance__cBoolean
field_channel_freelancermapSC_Publishing_Channel_Freelancermap__cBoolean
field_parent_jobhohr__Parent_Job__cText(18)Request-Id für Minggo-URL (F-330).
field_vis_requesthohr__Parent_Job__r.SC_ExternalRequestNo__cText(5)VIS-Request-No (Anzeige als Referenz).
field_owner_emailOwner.EmailText(80)String, keine User-Entity-Reference.
field_last_modifiedLastModifiedDateDatetimeFür Sortierung in Listing.
(kein Drupal-Feld)hohr__Job_Application_URL__cOutbound-only. URL via $node->toUrl() on-the-fly im OutboundWorker konstruiert.

Gedroppt vs. Excel-Mapping 0008: field_job_number (Parent_Job__r.SC_AutoNumber__c) — wird laut Excel-Author „irrelevant sobald RMS abgeschaltet wird", User-Bestätigung: existiert produktiv nicht. Total: 21 Storage-Felder + node.title + node.status.

Pull-Mechanik — Hybrid (Migrate inbound + Custom outbound + Per-Run-Sweep) D-3 + D-14

Migrate API + migrate_plus für SF→Drupal-Ingestion (folgt ADR-0013-Repo-Standard: idempotent über Map-Tabelle, integriert mit migrate_tools Cron+Drush). Custom QueueWorker + hook_node_update für Drupal→SF-Outbound (Migrate kennt keinen Bidirektional-Sync). MigrateEvents::POST_IMPORT-EventSubscriber für Hard-Delete-Sweep am Ende jedes Migrate-Runs.

Rejected: Pure Custom-QueueWorker für Inbound (bricht ADR-0013-Repo-Standard); Pure Migrate für Outbound (Migrate-Destinations nach Salesforce existieren nicht in migrate_plus); drupal/salesforce Suite (siehe Detail in ADR-0029: 6 zusätzliche Drupal-Module + 4 PHP-Libs + ext-soap-Pflicht + 58-commits-Switching-Cost + Mapping-UI-Mehrwert minimal bei 1 fixem Bundle/22 fixen Feldern + bricht ADR-0013).

Source-Plugin-Topologie — Custom Fetcher + Standard-OAuth2 + Standard-JSON-Parser

SchichtPluginSource
SourceurlStandard migrate_plus
Data-Fetchersalesforce_soqlCustom — SalesforceSoqlFetcher extends Http
Data-ParserjsonStandard migrate_plus
Authenticationoauth2 mit grant_type: refresh_tokenStandard migrate_plus (Sainsburys Guzzle-OAuth2)

Custom-Klasse SalesforceSoqlFetcher überschreibt nur getNextUrls(), um nextRecordsUrl aus dem JSON-Body zu lesen (Standard-Http liest Link: <…>; rel="next"-Header nach RFC 5988, das SF nicht implementiert). Credentials via $config-Override in settings.php aus Env-Vars SF_FCSB_CLIENT_ID, SF_FCSB_CLIENT_SECRET, SF_FCSB_REFRESH_TOKEN.

Auth-Sharing zwischen Inbound und Outbound — Strict-Separation

Migrate-Schiene behält das Standard-oauth2-Plugin (frischer Token pro Migration-Run via Sainsburys-Middleware). Outbound bekommt eigenen SalesforceClient-Service mit eigener getValidToken()-Logic (cached in cache.default mit ~50min TTL; SF-Bearer-Token gilt 1h). Bewusste Doppel-Auth: kostet nichts (SF Token-Endpoint hat kein relevantes Rate-Limit), bringt saubere Schicht-Trennung und unabhängige Testbarkeit.

Reconciliation — Per-Run-Sweep (Vacancy-Pattern 1:1)

Jeder Migrate-Run feuert am Ende MigrateEvents::POST_IMPORT. ProjectOfferHardDeleteSubscriber sammelt alle in diesem Run gesehenen SF-IDs, gleicht gegen migrate_map_project_offer-Tabelle ab, und löscht Drupal-Nodes, deren SF-ID in der Map ist, aber nicht im Run-Set. Direktes Port von solcom_vacancy/src/EventSubscriber/VacancyHardDeleteSubscriber.php.

Empty-Feed-Schutz: 0 Records im Run → Sweep skip + Watchdog-Warning, bestehende Nodes bleiben. HTTP-Fehler: Migration-Status failed → kein Sweep. Konsequenz für SOQL-Query: Vollscan (kein Last-Modified-Delta-Filter), sonst würde der Sweep false-positive-Deletes erzeugen.

Industry-Lookup — Custom Process Plugin + Taxonomy mit field_sf_value D-7

Vocabulary industry mit 25 Terms, jeder Term hat Name (DE-Label) + field_sf_value (SF-API-Value, English). Pre-Seed via solcom_project_offer.installhook_install. Custom Process Plugin project_offer_industry_lookup matcht TR1__Sector1__c-Value gegen field_sf_value. Auto-Create-on-Miss: neuer Term mit Name = SF-Value, Watchdog-Warning. Bewusste Abweichung vom Vacancy-Term-Generate-Pattern (silent auto-create), weil hier eine EN↔DE-Übersetzungs-Schicht hängt, die Editor-Pflege erfordert.

Rich-Text-Sanitize — Service-Klasse + Process Plugin (DI)

SalesforceRichTextSanitizer-Service in solcom_project_offer.services.yml. Nutzt DOMDocument + Whitelist: erlaubt p, br, strong, em, ul, ol, li, h2-h4, a; entfernt <font>, <o:p>, class="MsoNormal", alle inline-Styles, alle Tag-Attribute außer href auf <a>. Custom Process Plugin project_offer_richtext_sanitize injiziert den Service via ContainerFactoryPluginInterface. Vier Felder durchlaufen den Sanitizer: field_description, field_conditions, field_requirements, field_nice_to_have.

Lifecycle-Window-Auswertung — Render-Time (Views-Filter + Route-Access) D-6

Statt der ursprünglich angedachten static_map-Berechnung beim Migrate-Run (Lag bis 60min) wird das Publishing-Window render-time ausgewertet:

Rejected: Status beim Migrate-Run kombiniert berechnen (Lag bis 60min, schlechte UX bei knappen Bewerbungs-Fenstern); Cron-Hook jede Minute Status-Update (Trigger-Guard-Bruch + 60×/h Status-Saves würden Outbound-Queue fluten).

Outbound — Hooks + Queue + QueueWorker mit Trigger-Guard D-5

solcom_project_offer.module implementiert zwei Hooks (beide mit Bundle-Check $node->bundle() === 'project_offer'):

Action-Mapping: Insert-published oder Update-Transition-to-publishedaction: 'set'; Update-Transition-to-unpublishedaction: 'clear'. ProjectOfferOutboundWorker (annotated #[QueueWorker(id: 'project_offer_outbound', cron: ['time' => 60])]) holt Drupal-Node-URL ($node->toUrl('canonical', ['absolute' => TRUE])->toString()), patcht via SalesforceClient::patchProjectOffer($sf_id, ['hohr__Job_Application_URL__c' => $url|null]). Bei PATCH-Error: re-throw → Drupal-Queue retried mit Backoff.

Frontend-Decomposition — Layer A + B beide MVP-Pflicht D-2 + D-8b + D-11 + D-13

Der Frontend-Scope von F-290 wird nicht in MVP/Post-MVP gesplittet (ADR-0031 wird amended — ursprüngliche Layer-A/B-Trennung aufgelöst). Alle 14 Frontend-Sub-Tasks (P2503-137 bis P2503-152, minus die zwei race-condition-Duplikate -138 / -139) sind bis 2026-07-08 fertig.

Layer A — Baseline (P2503-137 bis P2503-146)

Sub-TaskInhaltFigma-Anker
P2503-137View-Mode-Config + Pathauto-Pattern /projektportal/projektangebote/[node:title]
P2503-140Canvas-Komposition project_offer.full (Detail-View)1019-78888
P2503-141Canvas-Card-Template project_offer.card (Listing-Row, baut auf byte_theme/card-SDC)985-50077
P2503-142Drupal-View project_offers_listing + 5 BEF-Filter (Branche, Auslastung, Einsatzort, Vollzeit/Teilzeit, Volltext)
P2503-143Canvas-Page /projektportal (Listing-Page-Layout)(Figma-Frame TBD)
P2503-144Custom-Block PPP-Cross-Sell (3 Karten zu Karriere / Sicher im Projekt / Projekt-Alert)
P2503-145Figma-MAPPING-Update + ADR-0031 amended
P2503-146Pre-Build-Gate: FCSB-Sandbox-Live-Describe für Skills + Remote-Anteil-Felder (oder Mapping via Long-Description-Parsing)

Layer B — Erweiterungen (P2503-147 bis P2503-152)

Sub-TaskInhaltKomplexität
P2503-147Skill-Badges auf Card + Skills-Filter (Mehrfach-Auswahl im BEF)Mittel — Vocabulary skill + Reference-Field field_skills oder Long-Description-Tag-Parsing
P2503-148Remote-Anteil-Slider auf Card + Range-Filter im BEFMittel — neuer Number-Field field_remote_share
P2503-149Suchradius-Filter (PLZ + 10/25/50/100 km Radius)Hoch — geocoder + geofield + Geocoder-Provider (OSM-Nominatim wegen DSGVO bevorzugt), field_geo Geofield-Storage. Eigener Pre-Build-Gate für Geocoder-Provider-Wahl.
P2503-150Merkliste (anon LocalStorage, max. 5, Heart-Icon, Overview-Page)Niedrig — pure Drupal-Behaviors + JS, kein Server-Roundtrip
P2503-151Matching-Rate auf Card (V1 faked, sticky via LocalStorage)Niedrig — JS-Berechnung im Behavior
P2503-152Projekt-Alert (E-Mail-Subscription + Cron-Dispatch)Hoch — eigene Entity project_offer_alert + Cron-Hook + Mail-Template, Submodul solcom_project_offer_alert

URL-Pfad + Pathauto

Pathauto-Pattern für project_offer-Nodes: /projektportal/projektangebote/[node:title]. Beispiel: /projektportal/projektangebote/sap-fi-co-berater-frankfurt. Pattern wird in config/sync/pathauto.pattern.project_offer.yml versioniert. Listing-Page lebt unter /projektportal (Canvas-Page, separates Node). Merkliste-Overview lebt unter /projektportal/merkliste (Drupal-Route ohne Node-Anker).

Konsequenz für Outbound: Die nach SF zurückgeschriebene URL ist die Detail-URL der Form https://solcom.de/projektportal/projektangebote/<alias>. Bei Alias-Änderung muss drush solcom-project-offer:push-urls --force einmal die SF-URLs neu schreiben.

JSON-LD nach schema.org/JobPosting

Custom-Service ProjectOfferJsonLdBuilder in solcom_project_offer.services.yml. hook_node_view($build, $entity, $display, $view_mode) hängt im full-View-Mode ein <script type="application/ld+json">-Tag ans $build-Array.

Mapping:

Prior Art: solcom_vacancy/src/Service/VacancyJsonLdBuilder.php — identisches Pattern, 1:1 als Schablone übernehmen. Rejected: schema_metatag-Modul (zu großer Footprint für einzelnen Builder).

Editor-Ausschluss

Permissions in solcom_project_offer.permissions.yml: keine Rolle hat edit any project_offer content oder create project_offer content. Selbst Admin-Edits würden beim nächsten Migrate-Run überschrieben — defensive UX zeigt Edit-Buttons nicht. Vacancy-Ausschluss-Pattern (ADR-0026) 1:1 übernommen.

Industry-Term-Pflege: Da Auto-Create-on-Miss neue Terms ohne DE-Label anlegt (Name = SF-Value), brauchen Editor:innen Edit-Rechte am Vocabulary industry (nicht am Bundle project_offer). Standard-Drupal-Permission edit terms in industry für die editor-Rolle.

Modul-Layout solcom_project_offer

web/modules/custom/solcom_project_offer/
├── solcom_project_offer.info.yml
├── solcom_project_offer.install        # hook_install (seed industry, 25 Terms) + hook_update_N (Rename-Migration)
├── solcom_project_offer.module         # hook_node_insert + hook_node_update (Trigger-Guard), hook_node_view (JSON-LD)
├── solcom_project_offer.permissions.yml
├── solcom_project_offer.services.yml   # SalesforceClient, SalesforceRichTextSanitizer, ProjectOfferJsonLdBuilder, ProjectOfferWindowAccess
├── solcom_project_offer.routing.yml    # Drush-Status-Route, Merkliste-Route
├── config/install/
│   └── migrate_plus.migration_group.project_offer.yml
├── src/
│   ├── Service/
│   │   ├── SalesforceClient.php
│   │   ├── SalesforceRichTextSanitizer.php
│   │   └── ProjectOfferJsonLdBuilder.php
│   ├── EventSubscriber/
│   │   └── ProjectOfferHardDeleteSubscriber.php
│   ├── Access/
│   │   └── ProjectOfferWindowAccess.php
│   ├── Plugin/
│   │   ├── migrate_plus/data_fetcher/
│   │   │   └── SalesforceSoqlFetcher.php
│   │   ├── migrate/process/
│   │   │   ├── ProjectOfferIndustryLookup.php
│   │   │   └── ProjectOfferRichtextSanitize.php
│   │   ├── QueueWorker/
│   │   │   └── ProjectOfferOutboundWorker.php
│   │   └── Block/
│   │       └── PppCrossSellBlock.php
│   └── Commands/
│       └── ProjectOfferCommands.php   # drush: bootstrap, reconcile, push-urls
├── modules/
│   └── solcom_project_offer_alert/   # Submodul für Projekt-Alert (E-Mail-Subscription)
│       ├── solcom_project_offer_alert.info.yml
│       ├── solcom_project_offer_alert.module
│       └── src/Entity/ProjectOfferAlert.php
└── tests/src/
    ├── Unit/Service/SalesforceRichTextSanitizerTest.php
    └── Kernel/
        ├── Plugin/migrate/process/ProjectOfferIndustryLookupTest.php
        ├── Plugin/migrate/process/ProjectOfferRichtextSanitizeTest.php
        ├── EventSubscriber/ProjectOfferHardDeleteSubscriberTest.php
        ├── Access/ProjectOfferWindowAccessTest.php
        ├── QueueWorker/ProjectOfferOutboundWorkerTest.php
        ├── Service/SalesforceClientTest.php
        ├── Service/ProjectOfferJsonLdBuilderTest.php
        └── ProjectOfferMigrationTest.php

Zusätzlich in config/sync/ (außerhalb des Moduls): migrate_plus.migration.project_offer.yml, node.type.project_offer.yml, taxonomy.vocabulary.industry.yml, ~21 Field-Storage/Field-Config-YAMLs, views.view.project_offers_listing.yml, canvas.content_template.node.project_offer.{full,card}.yml, pathauto.pattern.project_offer.yml.


Testing Decisions

Tests fokussieren auf externes Verhalten, nicht auf Implementation-Details. Deep Modules werden isoliert (Unit), Plugins gegen Drupal-Service-Container (Kernel), End-to-End gegen Guzzle-Mock (Functional).

TestTypCoverage-Ziel
SalesforceRichTextSanitizerTestUnitWhitelist-Tags bleiben; alle anderen entfernt; inline-Styles weg; <o:p>/MS-Word-Tags weg; UTF-8 erhalten; empty input → empty output.
ProjectOfferIndustryLookupTestKernelMatch auf field_sf_value; Auto-Create-on-Miss legt Term mit korrektem Value + Watchdog-Warning; idempotent bei mehrfachem Match.
ProjectOfferRichtextSanitizeTestKernelPlugin delegiert an Service; DI funktioniert; Force.com-Fixtures werden sauber gestrippt.
ProjectOfferHardDeleteSubscriberTestKernelItem verschwindet → gelöscht; Empty-Feed → kein Delete + Warning; HTTP-Fehler → kein Delete; migrate_map_project_offer-Lookup funktioniert.
ProjectOfferWindowAccessTestKernelnow innerhalb Window + status=1 → Access allowed; now vor Window → 404; now nach Window → 404; status=0 → 404 (unabhängig vom Window).
ProjectOfferOutboundWorkerTestKernel + Mocked-HTTPQueue-Item set → PATCH mit korrektem JSON-Body; clear → PATCH mit null; PATCH-401 → Re-Throw für Retry; PATCH-200 → done.
SalesforceClientTestKernel + Mocked-HTTPToken-Cache lädt nur einmal pro TTL; PATCH-Endpoint korrekt konstruiert; Error-Handling für 401/404/500; Refresh-Token-Flow bei 401-Response.
ProjectOfferJsonLdBuilderTestKernelJSON-LD-Output enthält alle 9 schema.org/JobPosting-Felder; UTF-8 escaped korrekt; validThrough ISO-8601 formatiert; missing optional Felder → kein Key im Output.
ProjectOfferMigrationTestKernel + FunctionalGuzzle-MockHandler liefert SOQL-Response mit nextRecordsUrl-Paging; Nodes entstehen mit allen 21 Feldern; Industry-Term wird korrekt referenziert; status-Mapping "Online" → 1 funktioniert.
ProjectOfferAlertSubscriptionTestKernelAlert-Entity wird angelegt; Cron-Dispatch matched neue Project-Offers gegen Filter-Kombination; Mail wird gesendet (gegen Mail-Collector); Unsubscribe-Link funktioniert.

Prior Art: solcom_vacancy/tests/src/Unit/Plugin/migrate/process/VacancyDescriptionsConcatTest.php (das einzige existierende Test-File in Custom-Modulen, Unit-Style) wird ergänzt durch die obigen Kernel/Functional-Tests. Test-Befehl: ddev drush phpunit web/modules/custom/solcom_project_offer/.

Layer-B-Frontend-Tests (Playwright/agent-browser, separates Test-Set): Merkliste (LocalStorage-Persistenz, max-5-Cap, Cross-Session-Stickiness); Skill-Filter (Mehrfach-Auswahl + URL-Sync); Suchradius (PLZ-Geocoding + Distance-Berechnung); Matching-Rate (sticky-per-Session).

Out of Scope (V1)

Open Items (vor / während Implementation zu klären)

  1. P2503-127 / P2503-146: Live SF-Describe gegen FCSB-SandboxSELECT FIELDS(STANDARD) FROM TR1__Job__c LIMIT 1 mit RecordType-Filter, plus /sobjects/TR1__Job__c/describe für Field-Metadata. Output gegen Context-Snapshot 0008 abgleichen. Pre-Build-Gate vor Field-Storage-Config-Finalisierung. Speziell für Layer-B: Custom-Felder für Skills + Remote-Anteil identifizieren.
  2. Steady-State Project-Offer-Count — SOQL-Count-Query SELECT COUNT(Id) FROM TR1__Job__c WHERE RecordTypeId = '012Wx000000C7ngIAC' gegen FCSB-Sandbox. Bestimmt Paging-Strategie und Quota-Sizing.
  3. Geocoder-Provider-Wahl für Suchradius (P2503-149) — OSM-Nominatim ist DSGVO-konform, hat aber Rate-Limits. Alternative: Google Maps Geocoding (kostenpflichtig, Vertrag nötig) oder lokale PLZ-DB. Entscheidung als Sub-Task-eigener-Pre-Build-Gate vor P2503-149-Implementation.
  4. Skills-Datenmodell (P2503-147) — abhängig vom Pre-Build-Gate-Output: existiert Custom-Field am TR1__Job__c oder muss Skills aus Long-Description geparst werden? Entscheidet, ob neues Vocabulary skill + Migration-Process oder Inline-Tag-Parsing.
  5. Customer-Lens-Reviewer-Konsultationsolcom-adr-workflow macht customer-lens-reviewer-Aufruf vor ADR-Accept zur Pflicht. Für die Backend-Sektionen dieses PRDs ist das Lens-Set (Figma / SDC / Theme) nicht 1:1 anwendbar — wird mit Hinweis „Backend-Sektionen N/A" konsultiert. Für die Frontend-Sektionen (Layer A + B) vollständig anwendbar.
  6. Rename-Refactor-PR-Strategie — atomarer Mega-PR vs. inkrementell (Bundle erst, dann Felder einzeln, dann Klassen)? Atomarer PR ist Review-anstrengend aber Migration-konsistent. Inkrementell wäre safer für CI, aber Map-Tabelle und Migration-YAML hängen zusammen. Empfehlung: atomar.

FAQ

Warum wurde sf_publicationproject_offer umbenannt — was hat das mit den 58 schon committeten Sub-Tasks zu tun?
Beim Grilling 2026-05-14 wurde die Live-Site solcom.de/de/projektportal als Domain-Sprach-Anker triangulierte. „Publication" ist BH4SF-RecordType-Implementierungs-Detail, nicht Domain-Begriff. Die 58 Commits werden im Rename-Refactor-PR (siehe „Domain-Naming-Migration"-Sektion) mit Update-Hooks und Config-Re-Export auf das neue Naming umgezogen. Temporär stale SF-URLs während des Re-Imports werden bewusst akzeptiert (Heilung beim nächsten Outbound-Cron).
Warum nicht die Drupal-Salesforce-Suite (drupal/salesforce)?
Bewusst rejected. Konkrete Kriterien: (a) 6 zusätzliche Drupal-Module + 4 PHP-Libs als Composer-Footprint; (b) ext-soap PHP-Extension als Hard-Requirement (Mittwald-Bestätigung unbestätigt); (c) bricht ADR-0013 (migrate_plus als Repo-Standard für externe Quellen); (d) Mapping-UI-Mehrwert ist Speculation bei 1 fixem Bundle / 22 fixen Feldern / einmaligem Setup; (e) 58 committe Custom-Code-Sub-Tasks (P2503-128 bis P2503-145) müssten verworfen werden, MVP-Termin 2026-07-08 wäre gefährdet; (f) Custom-Plugins für Industry-DE-Translation + RT-Sanitize wären bei Suite-Adoption trotzdem nötig. Vollständige Argumentation in ADR-0029 (amended). Re-Evaluation Phase 2, wenn weitere SF-Sync-Bundles kommen sollten.
Warum Lifecycle-Window render-time statt beim Migrate-Run?
UX-Schmerz: Bei Migrate-Run-basierter Status-Berechnung ist ein abgelaufenes Projektangebot bis zu 60 Minuten weiter sichtbar (bis nächster Cron). Bei zeitkritischen Anforderungen mit knapper Bewerbungsfrist bricht das User-Vertrauen. Views-Filter + Route-Access ist Drupal-idiomatisch (gleicher Pattern wie Scheduling-Modul nutzt) und hat keinen Lag.
Warum keine externe Plattform-Distribution aus Drupal direkt?
Die externen Plattformen (Gulp, Freelance.de, Freelancermap, evtl. Randstad) sind Outbound-Distributions-Ziele, aber die Distribution läuft SF-side via Boomi (oder Apex/Triggers). Drupal liefert nur die kanonische PPP-URL via Outbound-Writeback zurück nach SF; SF-Boomi orchestriert die Plattform-Pushes. Drupal-side-Direct-Push wäre architektonisch falsch (Drupal kennt SF-Plattform-Konfigurationen nicht).
Warum Layer B (Geo-Filter, Skill-Badges, Merkliste, Matching, Alert) im MVP statt Post-MVP?
Im Grilling 2026-05-14 festgelegt: kein Post-MVP für F-290. Alle Frontend-Features sind MVP-Pflicht bis 2026-07-08. ADR-0031 wird amended (Layer-A/B-Trennung aufgelöst). Implikation: Geo-Filter braucht eigenen Pre-Build-Gate (Geocoder-Provider-Wahl). Matching-Rate ist V1 als Placeholder akzeptiert (echte Logik = Phase 2 mit User-Profile-Pull).
Was passiert, wenn ein SF-Picklist-Value für Industry hinzukommt, den wir nicht in der Taxonomy haben?
Auto-Create: das Process Plugin legt einen neuen Term an mit Name = SF-Value (z. B. „Aerospace"), field_sf_value = „Aerospace". Watchdog-Warning loggt das. Editor:in muss DE-Label nachpflegen (Edit-Right an Vocabulary industry).
Was passiert, wenn ein Drupal-Editor versucht, ein project_offer zu editieren?
Permissions sind so gesetzt, dass keine Rolle edit any project_offer content hält. Edit-UI ist nicht erreichbar. Selbst Admin-Edits würden beim nächsten Migrate-Run überschrieben (analog Vacancy-Ausschluss, ADR-0026).
Wie sieht das Field-Mapping konkret aus?
Vollständig in Context-Snapshot 0008 (wird im Rename-Refactor nachgezogen). 21 Drupal-Storage-Felder + node.title + node.status. Industry-Picklist mit 25 EN→DE-Werten. SOQL-Anker-Query im Snapshot.
Wie wird der SF-Token-Refresh getestet, wenn die Sandbox-Credentials nicht im CI-Container liegen?
Migrate-Kernel-Test mockt den HTTP-Response via Guzzle-MockHandler — Token-Endpoint-Response wird simuliert. Live-Test (Re-Auth-Flow) ist Pre-Deploy-Check, kein CI-Gate.
Was, wenn der Geocoder-Provider (OSM-Nominatim) Rate-Limit hits?
P2503-149-Sub-Task hat eigenen Pre-Build-Gate. Bei Rate-Limit-Risiko: Cache-Layer für PLZ→Lat/Lng-Tupel (Drupal-cache.geocoder), kombiniert mit Pre-Seed der häufigsten 100 deutschen PLZs im hook_install. Notfall-Fallback: lokale PLZ-DB ohne Geocoder-Provider.
Was passiert mit der Merkliste, wenn ein Projektangebot offline geht, während es noch in der Merkliste eines Besuchers ist?
Die Merkliste speichert nur die SF-Id im LocalStorage. Beim Laden der Merkliste-Overview-Page lädt eine Drupal-Route alle aktiven Projektangebote zur Id-Liste — abgelaufene fallen aus dem Result (Window-Filter greift). Der Besucher sieht eine Notiz „1 Eintrag nicht mehr verfügbar" und kann ihn aus der Merkliste entfernen.