Skip to content

Phase 2A: personal opportunity discovery MVP - #6

Merged
abdo2006-dev merged 8 commits into
mainfrom
phase-2a/personal-opportunity-mvp
Aug 5, 2026
Merged

Phase 2A: personal opportunity discovery MVP#6
abdo2006-dev merged 8 commits into
mainfrom
phase-2a/personal-opportunity-mvp

Conversation

@abdo2006-dev

@abdo2006-dev abdo2006-dev commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the Phase 2A.1 Personal Opportunity MVP: import real Greenhouse listings with a local
TypeScript CLI, browse/search/filter them alongside manually-added opportunities, save/hide,
track applications through to a status, and keep interview-prep notes beside the exact pinned
listing snapshot — end to end against the real local Supabase stack and the real Greenhouse API.

Not merged — this PR is for review only, per the task instructions.

Working user flow

  1. supabase start (local Supabase stack)
  2. cd ingestion && npm run ingest -- --source greenhouse (all three reviewed boards)
  3. cd app && npm run dev, sign in, browse /opportunities (Discovered/Manual tabs)
  4. Search/filter, save/hide a listing
  5. /opportunities/import to add one from an unsupported source
  6. Open a listing, "Start application" → /applications/:id
  7. Change status (applied date sets automatically), add interview-prep notes
  8. Re-run ingestion — the application's pinned opportunity_version_id is unaffected

Schema (supabase/migrations/20260804100000_opportunity_foundation.sql)

  • Shared, service-role-write: sources, source_listings, opportunities,
    opportunity_versions (insert-only for every role, including service_role — immutability
    enforced at the grant level), ingestion_runs, and an opportunity_search read view.
  • All shared writes go through three trusted Postgres functions —
    begin_ingestion_run/apply_source_listing/finalize_ingestion_run — never a direct browser
    or even a direct service-role table grant beyond what those functions need. apply_source_listing
    is atomic: resolve/create the listing and opportunity, diff content hash, create a new version
    only on change, all in one call.
  • User-owned: private_opportunities (ADR-018), user_opportunity_state (save/hide — the
    implemented name for what an earlier doc draft called saved_opportunities, matching ADR-018's
    own usage), applications, interview_prep_notes.
  • Scope note: cross-source deduplication (ADR-010) is not implemented — one adapter can never
    produce a cross-source match to deduplicate, so source_listings.opportunity_id is 1:1 for now.
    Documented in DATA_MODEL.md and INGESTION_ARCHITECTURE.md, revisit when a second adapter is
    proposed.

Snapshot and pinning behavior

  • Saving a shared opportunity pins saved_opportunity_version_id; the detail page shows a
    "changed since you saved it" banner and both versions when the current version has moved on.
  • An application to a shared opportunity pins opportunity_version_id at creation; the browser has
    no update grant on that column, and a trigger blocks re-pointing a manual application's
    private_opportunity_id.
  • A manual application captures an immutable JSON snapshot of the private opportunity's fields at
    creation time (server-derived, not client-supplied); editing the private opportunity afterward
    never changes it. Deleting the private opportunity sets private_opportunity_id to null
    (on delete set null) while the snapshot and application survive.
  • Exactly one of (opportunity_version_id, manual snapshot) is enforced by both a check constraint
    and a trigger.

Application tracking

10 statuses, applied_at auto-set on leaving preparing, status_updated_at advances only on an
actual status change (an ordinary note edit doesn't touch it). contact_note on applications
holds general recruiter notes; interview_prep_notes holds the 8 interview-specific fields the
task describes (no duplicate contact-note field).

Greenhouse adapter (ingestion/)

Independent Node/TypeScript project. Fetch with retry (429/5xx only, honors Retry-After),
15s timeout, 8MB response cap, honest User-Agent. Hand-written parser (no zod for one adapter).
Sanitization: decode entities once (Greenhouse double-encodes its content field — discovered
against the real API), then an allowlist pass (sanitize-html), then plain text derived from the
sanitized tree. Deterministic SHA-256 content hash over exactly the historically-meaningful
fields (not source_metadata).

npm run ingest -- --source greenhouse                    # all enabled boards
npm run ingest -- --source greenhouse --board helsing     # one board
npm run ingest -- --source greenhouse --dry-run            # fetch + compare, zero writes

--dry-run never calls the trusted RPCs — verified against the real stack that it performs zero
writes (select count(*) from opportunities stayed 0 after a dry run that reported 132 would-create
records).

Reviewed boards (docs/DATA_SOURCES_AND_COMPLIANCE.md §3, all enabled = true — this task is the
owner's explicit authorization to enable them): helsing, marvelfusion, konux.

Search and filters

Postgres/PostgREST only, no external search service. Case-insensitive ilike across
title/organization/description/location on the opportunity_search view. Filters: kind,
employment type, remote mode, saved/hidden/applied (client-annotated from the user's own small
state set). Sort: recently discovered, recently posted, deadline soon, organization, title.
Range-based pagination (20/page).

Tests

  • pgTAP: 070_opportunity_foundation.test.sql (53 assertions — structure/grants/RLS, the three
    RPCs including created/unchanged/updated outcomes, immutability, conservative two-consecutive-
    absence closure, partial-run-closes-nothing), 080_private_and_applications.test.sql (55
    assertions — private opportunities, save/hide, applications' exactly-one-source and immutability
    rules, status/applied_at/status_updated_at behavior, interview notes, cross-user denials,
    snapshot-survives-deletion). Total suite: 349 pgTAP assertions across 9 files, all passing.
  • Ingestion: 38 vitest tests — sanitize (including the double-encoding fix), identity/hash,
    Greenhouse parse/classify/normalize, fetchGreenhouseBoard against a local node:http mock
    (valid/malformed/missing-jobs-array/retry-then-succeed/honest User-Agent), orchestrator dry-run
    (zero RPC calls) and real-run call-shape (begin/apply-per-record/finalize, malformed-record
    isolation, partial-page → partial status) against a fake Supabase client. No live network call.
  • Frontend: 89 vitest tests across 17 files (34 new) — loading/failure/retry/empty states,
    browsing real-shaped listings, search/filters, save/hide, manual entry validation, starting an
    application, status changes, the version-changed banner, pinned-snapshot display, interview-prep
    saving.
  • API integration (supabase/scripts/api-integration-test.mjs): extended with 16 new checks (67
    total) over real Auth → JWT → PostgREST → RLS → Postgres — reading/searching shared opportunities,
    the browser's inability to forge a shared opportunity/version directly, save/hide, manual
    opportunity creation, applications from both a shared version and a manual snapshot, automatic
    applied_at, pinned-version immutability, interview notes, and cross-user denials. Passes
    against the real local stack.

Essential security boundary

  • Shared tables: browser SELECT-only; every write goes through the three trusted RPCs, granted to
    service_role only (verified: authenticated gets 42501 calling them directly).
  • opportunity_versions: insert-only for every role, including service_role — no UPDATE/DELETE
    grant exists for anyone.
  • User-owned tables (private_opportunities, user_opportunity_state, applications,
    interview_prep_notes): owner-only RLS, one representative cross-user denial each.
  • promoted_to_opportunity_id: no browser write grant at all (reserved, trusted-only, unused).
  • Rendering: description text only, never raw HTML; external links use
    target="_blank" rel="noopener noreferrer".
  • Ingestion CLI: reads the service-role key only from .env.local (gitignored), refuses a
    non-loopback SUPABASE_URL unless explicitly overridden, never logs the key.

Demonstration performed (real data, real stack)

Ran npm run ingest -- --source greenhouse against the live Greenhouse API for all three boards:
151 real listings imported, 0 errors. Signed in, browsed and searched the real listings, saved
one, started an application, changed its status to applied (applied date set automatically),
added interview-prep notes, re-ran ingestion (idempotent — 0 new/updated), and confirmed the
application's pinned opportunity_version_id was unchanged and still version_number = 1. No real
listing data, personal data, or the service-role key is committed.

Explicit deferred features (not built here)

ECE relevance ranking, personalized competitiveness/acceptance probability, resume parsing,
projects/skills/preferences/work-eligibility matching, cloud deployment, Lever/Workable/
SmartRecruiters/BA/Workday adapters, arbitrary web scraping, LLM features, cross-source
deduplication (potential_duplicate_links), a standalone tasks table (covered at MVP by
applications.next_action/next_action_due_at).

Independent review corrections

A focused independent review of this PR found several core product-correctness issues, fixed here
in two additional commits (no history rewritten, nothing amended):

  • Stable application identity across listing versions. An application's "already applied?"
    check and its list-page "Applied" badge were keyed off the pinned opportunity_version_id, which
    re-ingestion intentionally never touches. Once re-ingestion created a newer current_version_id
    for the same opportunity, the lookup silently stopped matching: the opportunity looked un-applied
    again, and the detail page offered a duplicate "Start application". Added
    applications.shared_opportunity_id — a second FK to opportunities, derived server-side from
    opportunity_version_id in the existing applications_before_write trigger and immutable
    afterward (no browser grant exists on it). The opportunity list/detail pages, and
    applicationRepository, now key off this stable id; opportunity_version_id keeps its original
    job of pinning the exact content shown at application time.
  • Duplicate-prevention. Partial unique indexes on (user_id, shared_opportunity_id) and
    (user_id, private_opportunity_id) cap the MVP at one application tracker per user per listing.
    A repeat "start application" is rejected as a duplicate (23505); the UI catches this and
    navigates to the existing application instead of erroring.
  • Correct server-side state filtering. The opportunity list applied saved/hidden/applied-only
    filtering in React after an already-paginated page came back from search(), so the exact
    count and visible rows could both be wrong — a hidden row could occupy a page slot, and a
    matching row on another page could look like it didn't exist. opportunityRepository.search()
    now accepts includeOpportunityIds/excludeOpportunityIds, applied before .range() and the
    exact count; an empty include set short-circuits to zero rows, an empty exclude set applies no
    exclusion. The page also resets to 0 whenever any filter changes.
  • Location/source/lifecycle filters. Added a location-text filter (Bremen/Hamburg presets plus
    free text — explicitly plain-text matching, not geocoding) and source/lifecycle-status controls
    using the repository's existing sourceKey/lifecycleStatus support.
  • Editable manual source URL. private_opportunities.source_url is ordinary mutable
    owner-corrected metadata now (grant, trigger, repository type, and edit form all updated);
    correcting it never touches an application's already-captured snapshot, which was already a
    one-time copy taken at application creation.
  • Delete confirmations. A plain window.confirm before deleting an application (notes it also
    removes interview-prep notes) or a manual opportunity (notes an existing application's snapshot
    survives) — no custom modal framework.

Verification: pgTAP grew from 349 to 361 assertions (all pass), the live-stack API integration
script grew from 67 to 75 checks including a direct re-ingestion regression (all pass against the
real local stack), and the frontend suite grew from 89 to 95 tests (all pass), including pagination
tests using more than one page of fixtures so the prior filtering bug would have failed them. The
full regression — create a shared application, re-ingest a second version of the same listing,
confirm the application still resolves by stable id and still shows the original pinned content,
and confirm a second "start application" against the new version is still rejected as a duplicate —
was also verified manually against the real local stack with 134 real ingested Helsing listings.

Adds the Phase 2A.1 shared opportunity identity/version schema
(sources, source_listings, opportunities, opportunity_versions,
ingestion_runs, opportunity_search), private manual opportunities
(ADR-018), save/hide state, application tracking with immutable
pinned snapshots, and interview-prep notes.

All writes to the shared domain go through three narrow, trusted-only
functions (begin_ingestion_run, apply_source_listing,
finalize_ingestion_run) rather than direct table grants, so the CLI
can apply one observed listing atomically without ever being reachable
from the browser. Cross-source deduplication (ADR-010) is deliberately
not implemented yet, since a single adapter can never produce a match
to deduplicate against; source_listings.opportunity_id is 1:1 for now.

Updates DATA_MODEL.md, RLS_POLICY_MATRIX.md, and the source-compliance
registry to match, and adds pgTAP coverage for structure, grants, RLS,
the ingestion RPCs (including conservative closure), and the
application/private-opportunity correctness rules.
Splits primary navigation into Profile / Opportunities / Applications
(AppLayout), and adds the Discovered/Manual opportunity browsing
experience: search, kind/employment-type/remote-mode filters, sort,
range-based pagination, save/hide (with a show-hidden toggle), and the
manual-entry workflow (add, view, edit, hide, delete) for sources
CareerOS doesn't ingest automatically.

The opportunity detail page shows the current-vs-saved-version banner
when a saved listing's source content has changed, links to the
original source and application URLs safely (target=_blank,
rel=noopener noreferrer), and offers starting an application (wired
via applicationRepository, completed by the next commit's application
tracking UI).
Adds /applications (status filter, next-action/recent-update sort) and
/applications/:applicationId (status editor, next action, notes,
recruiter/contact notes, delete) so an application is a working
tracker, not just a database row editor.

The detail page renders the pinned listing snapshot beside an
editable interview-preparation form (responsibilities, required
technologies, topics to revise, likely questions, questions to ask,
interview date/format, post-interview reflections) and links out to
both the pinned opportunity and the authoritative external
application page.

Manually verified end-to-end against the real local stack: add a
manual opportunity, start an application, change its status (applied
date sets automatically), and save interview prep notes.
Adds ingestion/ as a second, independent Node/TypeScript project
implementing the Greenhouse Job Board API adapter end to end: fetch
(retry on 429/5xx, timeout, response-size cap, honest User-Agent),
a hand-written parser, entity-decode-once + sanitize-html + plain-text
normalization, deterministic content hashing, and a CLI (`ingest
--source greenhouse [--board <token>] [--dry-run]`).

All shared-table writes go through the three trusted RPCs from the
previous migration; --dry-run calls none of them and performs zero
writes, only reads for comparison. Adds an `ingestion` CI job (lint,
types, tests) using only synthetic fixtures and a local node:http
mock server -- no live third-party network call.

Two fixes discovered while verifying against the real, live Greenhouse
API for all three reviewed boards (helsing, marvelfusion, konux):
- opportunity_versions.description's length bound was too low for
  real postings (one board has entries over 100K sanitized characters);
  raised from 20000 to 50000, which covers all but a single extreme
  outlier, left as an expected per-record error rather than silently
  truncated content.
- Saving/hiding a shared opportunity used .upsert(), which requires
  UPDATE privilege on the ON CONFLICT columns (user_id, opportunity_id)
  even though only the state columns actually change -- switched to an
  explicit read-then-insert-or-update, since those ownership columns
  are deliberately not browser-writable.

Real local ingestion verified end to end: all three configured boards
imported cleanly (151 real listings, 0 errors after the description
fix), a rerun is idempotent (0 new/updated, all unchanged), and an
application's pinned opportunity_version_id is unaffected by a
subsequent re-ingestion of the same listing.
Adds frontend coverage for the six new opportunity/application pages
(loading, failure/retry, empty states, browsing real-shaped listings,
search/filters, save/hide, manual entry validation and submission,
starting an application, status changes with automatic applied_at,
the current-vs-saved-version banner, pinned-snapshot display, and
interview-prep note saving) -- 34 new tests across 89 total in the
frontend suite.

Extends the real Auth -> JWT -> PostgREST -> RLS -> Postgres
integration script with the Phase 2A.1 workflows: reading/searching
shared opportunities, the browser's inability to forge a shared
opportunity or version directly, save/hide, manual-opportunity
creation, an application from both a shared version and a manual
snapshot, automatic applied_at on status change, immutability of the
pinned version, interview-prep notes with server-derived ownership,
and cross-user denial for applications/manual opportunities/interview
notes -- 67 total checks, with the seeded shared fixture torn down
after the temporary users (applications reference opportunity_version_id
with no ON DELETE action, so user cleanup must happen first).
OpportunityListPage.test.tsx used vi.importActual on
../lib/opportunityRepository to reuse its type exports, which
transitively imports supabaseClient.ts and throws when
VITE_SUPABASE_URL/VITE_SUPABASE_PUBLISHABLE_KEY aren't set --
present locally via a gitignored .env.local, correctly absent in CI,
which is exactly why CI caught this and a local run didn't. Hardcodes
the one runtime value the mock actually needs (PAGE_SIZE) instead.

Verified by running the frontend test suite with .env.local removed,
matching CI conditions exactly.
An application's "is this already applied?" check and its Applied badge
were keyed off opportunity_version_id, which is deliberately pinned to
the exact content shown at application time. Once re-ingestion created a
newer current_version_id for the same opportunity, the lookup no longer
matched, silently un-marking the opportunity as applied and letting the
detail page offer a duplicate "Start application".

Add applications.shared_opportunity_id: a second FK to opportunities,
derived server-side from opportunity_version_id in
applications_before_write and immutable thereafter (no browser grant
exists on it at all). Partial unique indexes on
(user_id, shared_opportunity_id) and (user_id, private_opportunity_id)
cap the MVP at one application tracker per user per listing; a repeat
insert is rejected as a duplicate rather than silently accepted.

Update opportunityRepository/applicationRepository types and the
opportunity list/detail pages to key "applied" and "existing
application" lookups off the stable id, and to look up and navigate to
an existing application (via the new 23505 handling) instead of
attempting a second insert. Rename the application detail page's link
to the opportunity so it no longer implies that route is itself the
pinned version.

pgTAP and the live-stack integration script both add a direct
regression: create an application, re-ingest a second version of the
same listing, and confirm the application still resolves by stable id,
still shows the original pinned content, and a second "start
application" against the new version is still rejected as a duplicate.
The opportunity list applied saved/hidden/applied-only filtering in
React after a page had already come back from search(), so the exact
count and the visible rows could both be wrong: a hidden or unsaved row
could occupy a page slot, and a matching row on another page could look
like it didn't exist. Push those filters into the query itself.

opportunityRepository.search() now accepts includeOpportunityIds
(saved-only/applied-only; an empty array short-circuits to zero rows
without a request) and excludeOpportunityIds (hidden, unless "show
hidden" is on; an empty array applies no exclusion), applied before
.range() and the exact count. The page also resets to 0 whenever any
filter changes, and adds location-text filtering (with Bremen/Hamburg
presets, explicitly not geocoding) plus source and lifecycle-status
controls using the repository's existing sourceKey/lifecycleStatus
support.

private_opportunities.source_url becomes ordinary mutable
owner-corrected metadata: grant, trigger, repository type, and the edit
form all allow updating it, while an application's captured snapshot
stays untouched (it was already a one-time copy, never re-read live).

Add a plain window.confirm before deleting an application (noting that
its interview-prep notes go with it) or a manual opportunity (noting
that an existing application's snapshot survives), per the personal-MVP
scope -- no custom modal framework.

Frontend tests cover the corrected pagination behavior with more than
one page of fixtures (so the prior bug would have failed them), the new
filters, editable source_url, and both delete confirmations.
@abdo2006-dev
abdo2006-dev merged commit 00ed660 into main Aug 5, 2026
3 checks passed
@abdo2006-dev
abdo2006-dev deleted the phase-2a/personal-opportunity-mvp branch August 5, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant