project_offer-Sync (BH4SF) + PPP-Frontend — End-to-End-PRD für F-290
/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.
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_publication → project_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:
- Inbound (Migrate-Schiene): Stündlicher REST-Pull gegen
/services/data/v59.0/query, SOQL-Query mit RecordType-Filter, JSON-Response mitnextRecordsUrl-Pagination. OAuth-2.0 Refresh-Token-Flow. Custom Data-Fetcher (für SF-spezifisches JSON-Body-Paging), Standard-OAuth2-Plugin, Standard-JSON-Parser. Drupal-node.statuswird ausSC_PublicationStatus__c == "Online"abgeleitet (static_map). - Reconciliation (EventSubscriber-Schiene):
MigrateEvents::POST_IMPORTtriggertProjectOfferHardDeleteSubscriber; sweep der Migration-Map gegen das Run-Set, gelöscht-in-SF → gelöscht-in-Drupal. Empty-Feed-Schutz verhindert Total-Wipe bei API-Ausfall. - Outbound (QueueWorker-Schiene):
hook_node_insert+hook_node_updatebeobachten Publish-Status-Transitionen, enqueuen{nid, sf_id, action: set|clear}.ProjectOfferOutboundWorkerpatchthohr__Job_Application_URL__cvia REST-API. EigenerSalesforceClient-Service mit cached Token (~50min TTL). - Lifecycle-Window-Auswertung (Render-Time): Drupal-
node.statusist nur das SF-Online-Bit. Der Window-Checkpublish_start <= now < publish_endläuft render-time: im Views-Filter (für Listings) und inProjectOfferWindowAccess(Route-Access-Check für Direkt-URL-Zugriff). Damit kein 1h-Lag zwischen Window-Ablauf und Drupal-Unsichtbarkeit. - Frontend-Render-Schicht (`byte_theme` + Canvas): Canvas-Content-Templates
project_offer.full(Detail-View) undproject_offer.card(Listing-Row); Drupal-Viewproject_offers_listingmit Better-Exposed-Filters (Branche, Skills, Remote-Anteil, Ort + Suchradius, Vollzeit/Teilzeit); Canvas-Page/projektportalals Listing-Page-Layout mit Custom-Block PPP-Cross-Sell;schema.org/JobPosting-JSON-LD viaProjectOfferJsonLdBuilder(analog Vacancy). Layer-B-Features: Merkliste (anonymer LocalStorage, max. 5 Einträge), Matching-Rate (V1 als Placeholder-Logik), Projekt-Alert (E-Mail-Subscription).
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.
- Backend Content-Type
project_offerinconfig/sync/definiert (21 Storage-Fields gemäß Field-Naming-Konvention +node.title+node.status,drush cim-reproducible). - Backend Vocabulary
industrymit 25 Terms (DE-Labels +field_sf_value), perhook_installpre-seeded. - Backend Modul
solcom_project_offerim Default-Sync aktiviert;dev_splithandhabt nichts zusätzliches. - Backend Migration
project_offerläuft stündlich (Cron 3600s), idempotent über SF-Id, mitnextRecordsUrl-Paging und RecordType-Filter. - 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). - Backend Hard-Delete-Lifecycle: SF-verschwundene Records → Drupal-Node-Delete nach max. 1 Stunde. Empty-Feed-Schutz: 0 Records → Sweep skip + Watchdog-Warning.
- Backend Outbound-PATCH funktioniert: Publish-Transition →
hohr__Job_Application_URL__cgesetzt; Unpublish-Transition → Feld geleert. - Backend Outbound-Trigger-Guard verhindert Queue-Flooding während Migrate-Runs (nur Status-Transitionen, nicht jeder Save).
- Backend Industry-Lookup: Custom Process Plugin matcht SF-Value gegen
field_sf_value, Auto-Create-on-Miss mit Watchdog-Warning. - 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. - Backend Lifecycle-Window-Auswertung render-time:
node.statusreflektiert nurSC_PublicationStatus__c == "Online". Window-Checkpublish_start <= now < publish_endals Views-Filter (für Listings) +ProjectOfferWindowAccess(Route-Access-Check für Direkt-URL-Zugriff). - Backend Domain-Rename-Migration durchgezogen: Bundle
sf_publication→project_offer, Modulsolcom_sf_publication→solcom_project_offer, Vocabularysf_industry→industry, 21 Field-Storage-Migrationen nach Konvention (siehe Field-Naming), Migration-Map-Tabelle-Rename viahook_update_N. Update-Hooks idempotent. - Backend ADR-0029 amended: dokumentiert die Hybrid-Architektur unter dem neuen Naming und enthält eine explizite Rejected-Sektion „
drupal/salesforceSuite" mit den konkreten Kriterien (Composer-Footprint,ext-soap-Pflicht, ADR-0013-Bruch, 58-Commits-Switching-Cost). - Backend Tests pro Custom-Modul-Komponente (siehe Testing Decisions): Unit (Sanitizer), Kernel (Plugins, EventSubscriber, OutboundWorker, WindowAccess, JsonLdBuilder), Functional (End-to-End-Migration).
- Frontend A View-Mode-Configs (
card,full,teaser) + Pathauto-Pattern/projektportal/projektangebote/[node:title]inconfig/sync/. - Frontend A Canvas-Content-Template
project_offer.fullnach Figma-Frame1019-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). - Frontend A Canvas-Content-Template
project_offer.cardnach Figma-Frame985-50077(Listing-Row). Baut aufbyte_theme/card-SDC (prop-driven Variante der Card-Familie, Vacancy-Parität-Schablone). - Frontend A Drupal-View
project_offers_listingmit Better-Exposed-Filters: 5 Filter (Branche, Auslastung, Einsatzort, Vollzeit/Teilzeit-Flag (abgeleitet aus Auslastung-Range), Suchbegriff-Volltext). Sortierung default nachlast_modified DESC. - Frontend A Canvas-Page
/projektportalals Listing-Page-Layout: View-Embed, Cross-Sell-Block, SEO-Footer. Canvas-Region-Composition gemäß Figma-Frame. - Frontend A Custom-Block-Plugin „PPP-Cross-Sell" (config-driven, 3 verlinkte Karten zu Sister-Pages: Karriere, Sicher im Projekt, Projekt-Alert).
- Frontend A JSON-LD nach
schema.org/JobPostingviaProjectOfferJsonLdBuilder-Service +hook_node_view-Adapter (analog Vacancy-Pattern ADR-0013-inline). Gibttitle,description,datePosted(=field_publish_start),validThrough(=field_publish_end),employmentType: "CONTRACTOR",jobLocation(ausfield_city),industry(aus DE-Label),identifier(VIS-Request-No) aus. - Frontend A Figma-Sync-MAPPING aktualisiert (
docs/design/MAPPING.html): Frames1019-78888+985-50077aufproject_offer.full/.cardgemappt, Status matched. - 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__cexistiert oder Mapping via existierendem Long-Description-Tag erfolgt. - 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. - Frontend B Remote-Anteil-Slider in Card (0/25/50/75/100 %) + Range-Filter im BEF.
- 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). - 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. - 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).
- 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. - Backend ADR-0031 amended (Layer-A/B-Trennung aufgelöst — alle Frontend-Items in MVP integriert; Naming-Update auf
project_offer).
User Stories
- 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. - Als Freiberufler:in möchte ich offene SOLCOM-Projektangebote im PPP browsen ohne SF-Login, damit die Hürde zum Reinschauen minimal bleibt.
- 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.
- 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. - 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. - 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.
- 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). - Als Freiberufler:in möchte ich die Branche eines Projektangebots als deutsches Label sehen („Bankwesen" statt „Banking"), damit Suche und Filter natürlichsprachig funktionieren.
- 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. - Als Freiberufler:in möchte ich die VIS-Request-Number sehen können, damit ich bei Rückfragen einen eindeutigen Referenz-Identifier nennen kann.
- 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.
- Als System-Operator:in möchte ich, dass die SF-API-Credentials nicht im Git-Repository liegen (
$config-Override aus Env-VarsSF_FCSB_CLIENT_ID/_SECRET/_REFRESH_TOKEN), damit der Sicherheits-Standard eingehalten wird. - 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. - 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. - 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.
- 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.
- 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.
- 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 bestehendensolcom_vacancy. - 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. - 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.
- 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.
- 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.
- Als Suchmaschine (Google for Jobs, Bing) möchte ich, dass jedes Projektangebot strukturierte Daten nach
schema.org/JobPostingliefert, damit es in den entsprechenden Search-Snippets korrekt indexiert wird. - 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.
- Als Freiberufler:in möchte ich nach Skills filtern (Mehrfach-Auswahl), damit ich nur Projekte sehe, die zu meinem Tech-Stack passen.
- 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.
- 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.
- 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.
- 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.)
- 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.
- 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.
- 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):
- Modul-Directory-Rename
web/modules/custom/solcom_sf_publication/→solcom_project_offer/;*.info.yml+ Namespace-Update;composer.jsonautoload-PSR-4-Map. - 21 Field-Storage-Migrationen via
hook_update_N: pro Feld create-new → copy-data → uninstall-old. Reihenfolge nach Risk (Reference-Fields zuletzt). - Migration-Map-Tabelle-Rename:
ALTER TABLE migrate_map_sf_publication RENAME TO migrate_map_project_offer;viahook_update_N. Idempotent über Table-Exists-Check. - Config-Sync-Files in
config/sync/: 38 YAMLs renamed + Inhalt aktualisiert; Re-Export viadrush cex, Verify viadrush config:status. - Canvas-Templates:
canvas.content_template.node.sf_publication.{card,full}.yml→node.project_offer.{card,full}.yml. - View:
views.view.publications_listing.yml→views.view.project_offers_listing.yml; alle Filter-Identifier mit. - Block-Plugin-IDs:
solcom_sf_publication_pp_cross_sell→solcom_project_offer_pp_cross_sell. - Twig-Templates in
byte_theme/templates/und SDC-References: Bundle-Refs aktualisiert. - 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-urlseinmal manuell, um alle gesetzten URLs neu zu schreiben. - Jira-Sub-Task-Titel-Updates (P2503-128 bis P2503-145): via
acli jira workitem editin 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.
| Drupal | SF-API | Typ | Bemerkung |
|---|---|---|---|
node.title | TR1__Job_Title__c | Drupal-Built-in | Stellenbezeichnung. Kein eigenes Feld. |
node.status | SC_PublicationStatus__c | Drupal-Built-in | Berechnet via static_map: "Online" → 1, default → 0. |
field_sf_id | Id | Text(18), unique | External-Key, Map-Key für migrate_map_project_offer. |
field_description | hohr__Long_description__c | Long Text formatted | Sanitize-Pipeline. Aufgaben. |
field_conditions | hohr__Internal_description__c | Long Text formatted | Sanitize. Rahmenbedingungen. |
field_requirements | hohr__Requirements_description__c | Long Text formatted | Sanitize. Must-haves. |
field_nice_to_have | TR1__Experience_Requirements__c | Long Text formatted | Sanitize. |
field_utilisation | SC_Utilisation__c | Text(30) | Auslastung. |
field_estimated_start | TR1__Estimated_Start_Date__c | Date | Wunschstart. |
field_latest_start | SC_LatestStart__c | Date | Spätester Start. |
field_duration | SC_Duration__c | Text(50) | Dauer. |
field_city | TR1__City__c | Text(60) | Einsatzort. |
field_industry | TR1__Sector1__c | Entity Reference → industry | Custom Process Plugin Lookup. |
field_publish_start | hohr__Publishing_start_date_time__c | Datetime | Window-Anfang. |
field_publish_end | hohr__Publishing_end_date_time__c | Datetime | Window-Ende. |
field_channel_gulp | SC_Publishing_Channel_Gulp__c | Boolean | Outbound-Distributions-Channel. |
field_channel_freelance | SC_Publishing_Channel_Freelance__c | Boolean | — |
field_channel_freelancermap | SC_Publishing_Channel_Freelancermap__c | Boolean | — |
field_parent_job | hohr__Parent_Job__c | Text(18) | Request-Id für Minggo-URL (F-330). |
field_vis_request | hohr__Parent_Job__r.SC_ExternalRequestNo__c | Text(5) | VIS-Request-No (Anzeige als Referenz). |
field_owner_email | Owner.Email | Text(80) | String, keine User-Entity-Reference. |
field_last_modified | LastModifiedDate | Datetime | Für Sortierung in Listing. |
| (kein Drupal-Feld) | hohr__Job_Application_URL__c | — | Outbound-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
| Schicht | Plugin | Source |
|---|---|---|
| Source | url | Standard migrate_plus |
| Data-Fetcher | salesforce_soql | Custom — SalesforceSoqlFetcher extends Http |
| Data-Parser | json | Standard migrate_plus |
| Authentication | oauth2 mit grant_type: refresh_token | Standard 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.install → hook_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:
- Drupal-
node.statusreflektiert nurSC_PublicationStatus__c == "Online". Wird beim Migrate-Run viastatic_mapgesetzt. - Listings (Drupal-View
project_offers_listing): Views-Filter prüft zusätzlichfield_publish_start <= :nowANDfield_publish_end > :now. Damit ist die Listing-View immer auf den aktuellen Wand-Zeitpunkt synchron — kein 60min-Lag. - Direkt-URL-Zugriff (Bookmark, Google-Snippet, geteilter Link):
ProjectOfferWindowAccessalsEntityAccessControlHandler-Subscriber oder Route-Requirement. Wirft 404 (oder Redirect zur Listing-Page) wenn aktuellesnowaußerhalb des Windows liegt — selbst wenn der Nodestatus=1hat.
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'):
hook_node_insert($node)— bei erstmaliger Migrate-Erstellung. Enqueuedset, wenn der frisch erstellte Node published ist ($node->isPublished() === TRUE). Kein$node->original-Check, weiloriginalbeim Insertnullist.hook_node_update($node)— bei jedem späteren Save. Enqueued nur bei Status-Transition:$node->original->isPublished() !== $node->isPublished(). Verhindert Queue-Flooding bei stündlichen Migrate-Updates ohne Status-Wechsel.
Action-Mapping: Insert-published oder Update-Transition-to-published → action: 'set'; Update-Transition-to-unpublished → action: '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-Task | Inhalt | Figma-Anker |
|---|---|---|
| P2503-137 | View-Mode-Config + Pathauto-Pattern /projektportal/projektangebote/[node:title] | — |
| P2503-140 | Canvas-Komposition project_offer.full (Detail-View) | 1019-78888 |
| P2503-141 | Canvas-Card-Template project_offer.card (Listing-Row, baut auf byte_theme/card-SDC) | 985-50077 |
| P2503-142 | Drupal-View project_offers_listing + 5 BEF-Filter (Branche, Auslastung, Einsatzort, Vollzeit/Teilzeit, Volltext) | — |
| P2503-143 | Canvas-Page /projektportal (Listing-Page-Layout) | (Figma-Frame TBD) |
| P2503-144 | Custom-Block PPP-Cross-Sell (3 Karten zu Karriere / Sicher im Projekt / Projekt-Alert) | — |
| P2503-145 | Figma-MAPPING-Update + ADR-0031 amended | — |
| P2503-146 | Pre-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-Task | Inhalt | Komplexität |
|---|---|---|
| P2503-147 | Skill-Badges auf Card + Skills-Filter (Mehrfach-Auswahl im BEF) | Mittel — Vocabulary skill + Reference-Field field_skills oder Long-Description-Tag-Parsing |
| P2503-148 | Remote-Anteil-Slider auf Card + Range-Filter im BEF | Mittel — neuer Number-Field field_remote_share |
| P2503-149 | Suchradius-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-150 | Merkliste (anon LocalStorage, max. 5, Heart-Icon, Overview-Page) | Niedrig — pure Drupal-Behaviors + JS, kein Server-Roundtrip |
| P2503-151 | Matching-Rate auf Card (V1 faked, sticky via LocalStorage) | Niedrig — JS-Berechnung im Behavior |
| P2503-152 | Projekt-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:
title←node.titledescription←field_description(gesanitized, ohne HTML-Tags für JSON-LD)datePosted←field_publish_start(ISO-8601)validThrough←field_publish_end(ISO-8601)employmentType←"CONTRACTOR"(statisch, für Freelance-Projekte)jobLocation← strukturiert ausfield_cityindustry← DE-Label des referenzierten Industry-Termsidentifier←field_vis_request(VIS-Request-No, falls vorhanden, sonstfield_sf_id)hiringOrganization← statisch SOLCOM GmbH (Name, URL, Logo)
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).
| Test | Typ | Coverage-Ziel |
|---|---|---|
SalesforceRichTextSanitizerTest | Unit | Whitelist-Tags bleiben; alle anderen entfernt; inline-Styles weg; <o:p>/MS-Word-Tags weg; UTF-8 erhalten; empty input → empty output. |
ProjectOfferIndustryLookupTest | Kernel | Match auf field_sf_value; Auto-Create-on-Miss legt Term mit korrektem Value + Watchdog-Warning; idempotent bei mehrfachem Match. |
ProjectOfferRichtextSanitizeTest | Kernel | Plugin delegiert an Service; DI funktioniert; Force.com-Fixtures werden sauber gestrippt. |
ProjectOfferHardDeleteSubscriberTest | Kernel | Item verschwindet → gelöscht; Empty-Feed → kein Delete + Warning; HTTP-Fehler → kein Delete; migrate_map_project_offer-Lookup funktioniert. |
ProjectOfferWindowAccessTest | Kernel | now innerhalb Window + status=1 → Access allowed; now vor Window → 404; now nach Window → 404; status=0 → 404 (unabhängig vom Window). |
ProjectOfferOutboundWorkerTest | Kernel + Mocked-HTTP | Queue-Item set → PATCH mit korrektem JSON-Body; clear → PATCH mit null; PATCH-401 → Re-Throw für Retry; PATCH-200 → done. |
SalesforceClientTest | Kernel + Mocked-HTTP | Token-Cache lädt nur einmal pro TTL; PATCH-Endpoint korrekt konstruiert; Error-Handling für 401/404/500; Refresh-Token-Flow bei 401-Response. |
ProjectOfferJsonLdBuilderTest | Kernel | JSON-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. |
ProjectOfferMigrationTest | Kernel + Functional | Guzzle-MockHandler liefert SOQL-Response mit nextRecordsUrl-Paging; Nodes entstehen mit allen 21 Feldern; Industry-Term wird korrekt referenziert; status-Mapping "Online" → 1 funktioniert. |
ProjectOfferAlertSubscriptionTest | Kernel | Alert-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)
- User-Profile-Pull (
field_sf_contact_id+ SF-Contact → Drupal-User-Spiegelung). Phase 2, eigenes PRD nach Klärung der PPP-Auth-Strategie (Story F-400). - Bewerbungs-Form-Submit nach Bullhorn — gehört zu F-330 / Minggoformular (iFrame-Embed, kein Drupal-Datendurchsatz).
- Multi-Field-Outbound — nur
hohr__Job_Application_URL__cwird zurückgeschrieben. Andere SF-Felder bleiben SF-side-only. - Salesforce Push / Platform Events — REST-Pull entschieden; Push wäre eigenes Architektur-Item (separate ADR im Phase-2-Scope).
- Editorial-Workflow für project_offer — ADR-0026-Vacancy-Ausschluss-Präzedenz, keine Scheduler-/Moderation-State-UI.
- Bullhorn-Direct-Integration — nur BH4SF via Salesforce, kein eigener Bullhorn-API-Client.
- OAuth-2 JWT-Bearer-Flow — Refresh-Token-Flow für V1; JWT-Bearer kann später für Production nachgezogen werden.
- Delta-Sync via Last-Modified-Filter — Vollscan via Per-Run-Sweep, kein Delta-Filter (sonst false-positive-Deletes).
- Multi-Source-Bundle —
project_offerist single-source-Inbound (nur BH4SF). Phase 2 entscheidet, ob weitere SF-Quellen ins gleiche Bundle oder eigene Bundles bekommen. Aktuell keine konkrete zweite Quelle in Roadmap. - Externe Plattform-Outbound-Pushes (Gulp/Freelance.de/Freelancermap/Randstad) — laufen SF-side via Boomi, nicht Drupal-side. Drupal liefert nur die URL-Referenz zurück.
- Echte Matching-Logik — V1 ist Placeholder (faked Random 60–95 %). Echte Logik braucht User-Profile-Pull aus Phase 2.
- Drupal-User-Account für Merkliste — V1 ist anonymer LocalStorage. User-Account-basierte persistente Merklisten kommen mit F-400 (PPP-Auth).
Open Items (vor / während Implementation zu klären)
- P2503-127 / P2503-146: Live SF-Describe gegen FCSB-Sandbox —
SELECT FIELDS(STANDARD) FROM TR1__Job__c LIMIT 1mit RecordType-Filter, plus/sobjects/TR1__Job__c/describefü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. - 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. - 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.
- Skills-Datenmodell (P2503-147) — abhängig vom Pre-Build-Gate-Output: existiert Custom-Field am
TR1__Job__coder muss Skills aus Long-Description geparst werden? Entscheidet, ob neues Vocabularyskill+ Migration-Process oder Inline-Tag-Parsing. - Customer-Lens-Reviewer-Konsultation —
solcom-adr-workflowmachtcustomer-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. - 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_publication→project_offerumbenannt — was hat das mit den 58 schon committeten Sub-Tasks zu tun? - Beim Grilling 2026-05-14 wurde die Live-Site
solcom.de/de/projektportalals 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-soapPHP-Extension als Hard-Requirement (Mittwald-Bestätigung unbestätigt); (c) bricht ADR-0013 (migrate_plusals 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 Vocabularyindustry). - Was passiert, wenn ein Drupal-Editor versucht, ein project_offer zu editieren?
- Permissions sind so gesetzt, dass keine Rolle
edit any project_offer contenthä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.