https://learn.spikersoft.com/employment renders ~33 near-identical cards in flat grids — 19 seeded org roles plus 14 ambassador rows synthesized on the fly from active Locations. Every card inlines its full description and its requirements list, and each of the 7 sections carries a prose blurb, so the page is a wall of words with:
no search, no filter, no detail view, and no per-role URL — a role cannot be shared as a link;
14 Ambassador - <Country> cards that differ only by country name (the bulk of the duplication);
no geography at all on the 19 org roles — only ambassador rows carry a countryCode. SpikerSoft is going global and the page cannot express that.
Outcome
A routed master–detail careers page:
Left: job titles in a listbox (@angular/aria/listbox).
Middle: position details, deep-linkable at /employment/:slug so a role can be shared directly.
Right (desktop/tablet): a 3D globe — the sponsor-family "About their dream" globe, extracted into a shared lib — showing where SpikerSoft operates and where roles are open.
Top: filters by country, work mode, and section.
Far less prose: the "most roles are remote, the team is global, everyone is invited to company functions" messaging lives in one subtitle line instead of nine paragraphs.
Phones: list → full-screen detail; no globe (three.js and the ~1.3 MB of GeoJSON never download).
Scope
Backend (spikersoft-backend)
OpenPosition gains nullablecountries: List<string>? (ISO-3; empty = worldwide) and workMode: string? (remote/hybrid/onsite), plus a PositionWorkModes constants class.
Nullability is mandatory per the #369/#606 schema-evolution rule — the EF-Mongo shaper rejects a missing element for a non-nullable property, and List<T> is not exempt. A required field here would 500 anonymous GET /api/positions for every /employment visitor.
PositionSeederService carries per-role values (11 USA-scoped, 8 worldwide, all remote) and gains a reconcile pass that runs before the toInsert.Count == 0 early return — in prod that early return is always taken, so heal logic appended after it would be dead code.
PositionDto gains the two fields appended at the end, with named arguments at the 8 call sites (the record is positional and the adjacent types match, so a mid-record insert would silently re-bind with no compiler error).
TryNormalizeGeo validation shared across POST / PUT / PUT {id}/requirements, validated against the active locations collection; changelog entries written only on actual change.
[AllowAnonymous] on GetOpenPositions and LocationsController.GetActiveLocations (guards the in-flight FallbackPolicy, #415 / #312).
Committed mongodb-scripts/2026-07-add-position-geo.js backfill — prod API replicas run with Seeding__RunOnStartup=false and no workflow re-triggers the bootstrap job.
New OpenPositionLegacyDocumentTests (BSON-level, FakeMongoClientWrapper) — the existing controller tests use Moq'd DbSets and structurally cannot catch the nullability trap.
Frontend (spikersoft-angular)
New libraries/features/geo-globe (@spikersoft/feature-geo-globe) — the sponsor globe, parameterized: tone + palette instead of the hardcoded shared boolean, opt-in stateCountries (careers fetches 1 file / 254 KB instead of 14 / ~1.3 MB), height, and hintText/unavailableText as plain string inputs so the lib carries no Transloco dependency. Must be a feature lib — it injects HttpClient and imports @spikersoft/domain-geo, both illegal for layer:ui.
sponsor-family migrated to the lib in the same change; family-globe.component.* deleted.
/employment reworked: shell + :slug child route, aria listbox, filter bar mirrored to query params, detail pane, and the 14 ambassador rows collapsed to one "Country Ambassador" entry whose detail shows a country grid (open vs filled) — clicking a country still applies with that country's real positionId, so the existing application dialog is untouched.
PositionService.getOpenPositions() replaced by a @Service() + httpResource() catalog per the workspace's read-as-resources rule; the two admin consumers are untouched.
Slugs derive from seedKey ?? slugify(backend English title) — never from the translated title, or links shared from the Spanish site would 404 in English.
Ships as two PRs (backend, then frontend). The frontend normalizes countries ?? [] / workMode ?? "remote", so it degrades safely if it deploys first.
Follow-ups to file separately: admin editing of countries/workMode in hiring-management; per-country ambassador deep links; the duplicate alpha2→alpha3 table in GeoNameService.
Filed by Opus 5 Agent.
## Problem
`https://learn.spikersoft.com/employment` renders **~33 near-identical cards** in flat grids — 19 seeded org roles plus 14 ambassador rows synthesized on the fly from active `Locations`. Every card inlines its full description *and* its requirements list, and each of the 7 sections carries a prose blurb, so the page is a wall of words with:
- no search, no filter, no detail view, and **no per-role URL** — a role cannot be shared as a link;
- 14 `Ambassador - <Country>` cards that differ only by country name (the bulk of the duplication);
- **no geography at all on the 19 org roles** — only ambassador rows carry a `countryCode`. SpikerSoft is going global and the page cannot express that.
## Outcome
A routed master–detail careers page:
- **Left:** job titles in a listbox (`@angular/aria/listbox`).
- **Middle:** position details, deep-linkable at `/employment/:slug` so a role can be shared directly.
- **Right (desktop/tablet):** a 3D globe — the sponsor-family "About their dream" globe, extracted into a shared lib — showing where SpikerSoft operates and where roles are open.
- **Top:** filters by country, work mode, and section.
- Far less prose: the "most roles are remote, the team is global, everyone is invited to company functions" messaging lives in one subtitle line instead of nine paragraphs.
- **Phones:** list → full-screen detail; no globe (three.js and the ~1.3 MB of GeoJSON never download).
## Scope
### Backend (`spikersoft-backend`)
- `OpenPosition` gains **nullable** `countries: List<string>?` (ISO-3; empty = worldwide) and `workMode: string?` (`remote`/`hybrid`/`onsite`), plus a `PositionWorkModes` constants class.
- Nullability is mandatory per the #369/#606 schema-evolution rule — the EF-Mongo shaper rejects a missing element for a non-nullable property, and `List<T>` is not exempt. A required field here would 500 anonymous `GET /api/positions` for every `/employment` visitor.
- `PositionSeederService` carries per-role values (11 USA-scoped, 8 worldwide, all `remote`) and gains a **reconcile pass that runs before the `toInsert.Count == 0` early return** — in prod that early return is always taken, so heal logic appended after it would be dead code.
- `PositionDto` gains the two fields **appended at the end**, with named arguments at the 8 call sites (the record is positional and the adjacent types match, so a mid-record insert would silently re-bind with no compiler error).
- `TryNormalizeGeo` validation shared across `POST` / `PUT` / `PUT {id}/requirements`, validated against the active `locations` collection; changelog entries written only on actual change.
- `[AllowAnonymous]` on `GetOpenPositions` and `LocationsController.GetActiveLocations` (guards the in-flight `FallbackPolicy`, #415 / #312).
- Committed `mongodb-scripts/2026-07-add-position-geo.js` backfill — prod API replicas run with `Seeding__RunOnStartup=false` and no workflow re-triggers the bootstrap job.
- New `OpenPositionLegacyDocumentTests` (BSON-level, `FakeMongoClientWrapper`) — the existing controller tests use Moq'd `DbSet`s and structurally cannot catch the nullability trap.
### Frontend (`spikersoft-angular`)
- New `libraries/features/geo-globe` (`@spikersoft/feature-geo-globe`) — the sponsor globe, parameterized: `tone` + palette instead of the hardcoded `shared` boolean, opt-in `stateCountries` (careers fetches 1 file / 254 KB instead of 14 / ~1.3 MB), `height`, and `hintText`/`unavailableText` as plain string inputs so the lib carries no Transloco dependency. Must be a `feature` lib — it injects `HttpClient` and imports `@spikersoft/domain-geo`, both illegal for `layer:ui`.
- `sponsor-family` migrated to the lib in the same change; `family-globe.component.*` deleted.
- `/employment` reworked: shell + `:slug` child route, aria listbox, filter bar mirrored to query params, detail pane, and the 14 ambassador rows collapsed to **one "Country Ambassador" entry** whose detail shows a country grid (open vs filled) — clicking a country still applies with that country's real `positionId`, so the existing application dialog is untouched.
- `PositionService.getOpenPositions()` replaced by a `@Service()` + `httpResource()` catalog per the workspace's read-as-resources rule; the two admin consumers are untouched.
- Slugs derive from `seedKey ?? slugify(backend English title)` — never from the translated title, or links shared from the Spanish site would 404 in English.
- Existing `business.hiring` analytics event names preserved.
## Notes
- Ships as two PRs (backend, then frontend). The frontend normalizes `countries ?? []` / `workMode ?? "remote"`, so it degrades safely if it deploys first.
- Follow-ups to file separately: admin editing of `countries`/`workMode` in hiring-management; per-country ambassador deep links; the duplicate alpha2→alpha3 table in `GeoNameService`.
Filed by Opus 5 Agent.
Resolved in spikersoft-backend PR #492 (6430e271) + spikersoft-angular PR #588 (72808f7d), both merged to master. Verified against origin/master:
Backend
SpikerSoft.Data/Mongos/OpenPosition.cs:58-68 — nullable Countries / WorkMode; PositionWorkModes.cs added.
PositionSeederService.cs:165 — ReconcileGeoFields is called before the :189toInsert.Count == 0 early return, so existing deployments actually get backfilled rather than skipping.
PositionsController.cs:396 — shared NormalizeGeoAsync used by POST/PUT/requirements (:196,232,287), with changelog-on-change at :446-457.
Anonymous access: [AllowAnonymous] at PositionsController.cs:21 and LocationsController.cs:40.
Migration mongodb-scripts/2026-07-add-position-geo.js; legacy-document test OpenPositionLegacyDocumentTests.cs.
Frontend
Globe extracted to libraries/features/geo-globe (@spikersoft/feature-geo-globe); the old family-globe.component.* is deleted and sponsor-family.component.ts:16 now consumes the lib — no duplicate globe left behind.
ARIA listbox via primitives at employment-list.component.ts:2,18 (matches the @angular/aria rule).
Filters mirrored to query params employment.component.ts:123-154; ambassador collapse in employment-detail/ambassador-countries.component.ts:8.
position-catalog.service.ts:17 uses httpResource per the resource-APIs rule.
Globe is @defer (on viewport) inside a desktop-only @if (employment.component.html:65-85), so mobile never pays for it.
Closing.
Resolved in spikersoft-backend PR #492 (`6430e271`) + spikersoft-angular PR #588 (`72808f7d`), both merged to `master`. Verified against `origin/master`:
**Backend**
- `SpikerSoft.Data/Mongos/OpenPosition.cs:58-68` — nullable `Countries` / `WorkMode`; `PositionWorkModes.cs` added.
- `PositionSeederService.cs:165` — `ReconcileGeoFields` is called **before** the `:189` `toInsert.Count == 0` early return, so existing deployments actually get backfilled rather than skipping.
- `PositionsController.cs:396` — shared `NormalizeGeoAsync` used by POST/PUT/requirements (`:196,232,287`), with changelog-on-change at `:446-457`.
- Anonymous access: `[AllowAnonymous]` at `PositionsController.cs:21` and `LocationsController.cs:40`.
- Migration `mongodb-scripts/2026-07-add-position-geo.js`; legacy-document test `OpenPositionLegacyDocumentTests.cs`.
**Frontend**
- Globe extracted to `libraries/features/geo-globe` (`@spikersoft/feature-geo-globe`); the old `family-globe.component.*` is deleted and `sponsor-family.component.ts:16` now consumes the lib — no duplicate globe left behind.
- Routed master-detail: `projects/spikersoft/src/routes.ts:560-569` shell + `:slug` child.
- ARIA listbox via primitives at `employment-list.component.ts:2,18` (matches the `@angular/aria` rule).
- Filters mirrored to query params `employment.component.ts:123-154`; ambassador collapse in `employment-detail/ambassador-countries.component.ts:8`.
- `position-catalog.service.ts:17` uses `httpResource` per the resource-APIs rule.
- Globe is `@defer (on viewport)` inside a desktop-only `@if` (`employment.component.html:65-85`), so mobile never pays for it.
Closing.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
https://learn.spikersoft.com/employmentrenders ~33 near-identical cards in flat grids — 19 seeded org roles plus 14 ambassador rows synthesized on the fly from activeLocations. Every card inlines its full description and its requirements list, and each of the 7 sections carries a prose blurb, so the page is a wall of words with:Ambassador - <Country>cards that differ only by country name (the bulk of the duplication);countryCode. SpikerSoft is going global and the page cannot express that.Outcome
A routed master–detail careers page:
@angular/aria/listbox)./employment/:slugso a role can be shared directly.Scope
Backend (
spikersoft-backend)OpenPositiongains nullablecountries: List<string>?(ISO-3; empty = worldwide) andworkMode: string?(remote/hybrid/onsite), plus aPositionWorkModesconstants class.List<T>is not exempt. A required field here would 500 anonymousGET /api/positionsfor every/employmentvisitor.PositionSeederServicecarries per-role values (11 USA-scoped, 8 worldwide, allremote) and gains a reconcile pass that runs before thetoInsert.Count == 0early return — in prod that early return is always taken, so heal logic appended after it would be dead code.PositionDtogains the two fields appended at the end, with named arguments at the 8 call sites (the record is positional and the adjacent types match, so a mid-record insert would silently re-bind with no compiler error).TryNormalizeGeovalidation shared acrossPOST/PUT/PUT {id}/requirements, validated against the activelocationscollection; changelog entries written only on actual change.[AllowAnonymous]onGetOpenPositionsandLocationsController.GetActiveLocations(guards the in-flightFallbackPolicy, #415 / #312).mongodb-scripts/2026-07-add-position-geo.jsbackfill — prod API replicas run withSeeding__RunOnStartup=falseand no workflow re-triggers the bootstrap job.OpenPositionLegacyDocumentTests(BSON-level,FakeMongoClientWrapper) — the existing controller tests use Moq'dDbSets and structurally cannot catch the nullability trap.Frontend (
spikersoft-angular)libraries/features/geo-globe(@spikersoft/feature-geo-globe) — the sponsor globe, parameterized:tone+ palette instead of the hardcodedsharedboolean, opt-instateCountries(careers fetches 1 file / 254 KB instead of 14 / ~1.3 MB),height, andhintText/unavailableTextas plain string inputs so the lib carries no Transloco dependency. Must be afeaturelib — it injectsHttpClientand imports@spikersoft/domain-geo, both illegal forlayer:ui.sponsor-familymigrated to the lib in the same change;family-globe.component.*deleted./employmentreworked: shell +:slugchild route, aria listbox, filter bar mirrored to query params, detail pane, and the 14 ambassador rows collapsed to one "Country Ambassador" entry whose detail shows a country grid (open vs filled) — clicking a country still applies with that country's realpositionId, so the existing application dialog is untouched.PositionService.getOpenPositions()replaced by a@Service()+httpResource()catalog per the workspace's read-as-resources rule; the two admin consumers are untouched.seedKey ?? slugify(backend English title)— never from the translated title, or links shared from the Spanish site would 404 in English.business.hiringanalytics event names preserved.Notes
countries ?? []/workMode ?? "remote", so it degrades safely if it deploys first.countries/workModein hiring-management; per-country ambassador deep links; the duplicate alpha2→alpha3 table inGeoNameService.Filed by Opus 5 Agent.
Resolved in spikersoft-backend PR #492 (
6430e271) + spikersoft-angular PR #588 (72808f7d), both merged tomaster. Verified againstorigin/master:Backend
SpikerSoft.Data/Mongos/OpenPosition.cs:58-68— nullableCountries/WorkMode;PositionWorkModes.csadded.PositionSeederService.cs:165—ReconcileGeoFieldsis called before the:189toInsert.Count == 0early return, so existing deployments actually get backfilled rather than skipping.PositionsController.cs:396— sharedNormalizeGeoAsyncused by POST/PUT/requirements (:196,232,287), with changelog-on-change at:446-457.[AllowAnonymous]atPositionsController.cs:21andLocationsController.cs:40.mongodb-scripts/2026-07-add-position-geo.js; legacy-document testOpenPositionLegacyDocumentTests.cs.Frontend
libraries/features/geo-globe(@spikersoft/feature-geo-globe); the oldfamily-globe.component.*is deleted andsponsor-family.component.ts:16now consumes the lib — no duplicate globe left behind.projects/spikersoft/src/routes.ts:560-569shell +:slugchild.employment-list.component.ts:2,18(matches the@angular/ariarule).employment.component.ts:123-154; ambassador collapse inemployment-detail/ambassador-countries.component.ts:8.position-catalog.service.ts:17useshttpResourceper the resource-APIs rule.@defer (on viewport)inside a desktop-only@if(employment.component.html:65-85), so mobile never pays for it.Closing.