Phase 2A: personal opportunity discovery MVP - #6
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
supabase start(local Supabase stack)cd ingestion && npm run ingest -- --source greenhouse(all three reviewed boards)cd app && npm run dev, sign in, browse/opportunities(Discovered/Manual tabs)/opportunities/importto add one from an unsupported source/applications/:idopportunity_version_idis unaffectedSchema (
supabase/migrations/20260804100000_opportunity_foundation.sql)sources,source_listings,opportunities,opportunity_versions(insert-only for every role, including service_role — immutabilityenforced at the grant level),
ingestion_runs, and anopportunity_searchread view.begin_ingestion_run/apply_source_listing/finalize_ingestion_run— never a direct browseror even a direct service-role table grant beyond what those functions need.
apply_source_listingis atomic: resolve/create the listing and opportunity, diff content hash, create a new version
only on change, all in one call.
private_opportunities(ADR-018),user_opportunity_state(save/hide — theimplemented name for what an earlier doc draft called
saved_opportunities, matching ADR-018'sown usage),
applications,interview_prep_notes.produce a cross-source match to deduplicate, so
source_listings.opportunity_idis 1:1 for now.Documented in
DATA_MODEL.mdandINGESTION_ARCHITECTURE.md, revisit when a second adapter isproposed.
Snapshot and pinning behavior
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.
opportunity_version_idat creation; the browser hasno update grant on that column, and a trigger blocks re-pointing a manual application's
private_opportunity_id.creation time (server-derived, not client-supplied); editing the private opportunity afterward
never changes it. Deleting the private opportunity sets
private_opportunity_idto null(
on delete set null) while the snapshot and application survive.opportunity_version_id, manual snapshot) is enforced by both a check constraintand a trigger.
Application tracking
10 statuses,
applied_atauto-set on leavingpreparing,status_updated_atadvances only on anactual status change (an ordinary note edit doesn't touch it).
contact_noteonapplicationsholds general recruiter notes;
interview_prep_notesholds the 8 interview-specific fields thetask 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
zodfor one adapter).Sanitization: decode entities once (Greenhouse double-encodes its
contentfield — discoveredagainst the real API), then an allowlist pass (
sanitize-html), then plain text derived from thesanitized tree. Deterministic SHA-256 content hash over exactly the historically-meaningful
fields (not
source_metadata).--dry-runnever calls the trusted RPCs — verified against the real stack that it performs zerowrites (
select count(*) from opportunitiesstayed 0 after a dry run that reported 132 would-createrecords).
Reviewed boards (
docs/DATA_SOURCES_AND_COMPLIANCE.md§3, allenabled = true— this task is theowner's explicit authorization to enable them):
helsing,marvelfusion,konux.Search and filters
Postgres/PostgREST only, no external search service. Case-insensitive
ilikeacrosstitle/organization/description/location on the
opportunity_searchview. 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
070_opportunity_foundation.test.sql(53 assertions — structure/grants/RLS, the threeRPCs including created/unchanged/updated outcomes, immutability, conservative two-consecutive-
absence closure, partial-run-closes-nothing),
080_private_and_applications.test.sql(55assertions — private opportunities, save/hide, applications' exactly-one-source and immutability
rules, status/
applied_at/status_updated_atbehavior, interview notes, cross-user denials,snapshot-survives-deletion). Total suite: 349 pgTAP assertions across 9 files, all passing.
Greenhouse parse/classify/normalize,
fetchGreenhouseBoardagainst a localnode:httpmock(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 →
partialstatus) against a fake Supabase client. No live network call.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.
supabase/scripts/api-integration-test.mjs): extended with 16 new checks (67total) 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. Passesagainst the real local stack.
Essential security boundary
service_roleonly (verified:authenticatedgets42501calling them directly).opportunity_versions: insert-only for every role, includingservice_role— no UPDATE/DELETEgrant exists for anyone.
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).target="_blank" rel="noopener noreferrer"..env.local(gitignored), refuses anon-loopback
SUPABASE_URLunless explicitly overridden, never logs the key.Demonstration performed (real data, real stack)
Ran
npm run ingest -- --source greenhouseagainst 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_idwas unchanged and stillversion_number = 1. No reallisting 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 standalonetaskstable (covered at MVP byapplications.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):
check and its list-page "Applied" badge were keyed off the pinned
opportunity_version_id, whichre-ingestion intentionally never touches. Once re-ingestion created a newer
current_version_idfor 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 toopportunities, derived server-side fromopportunity_version_idin the existingapplications_before_writetrigger and immutableafterward (no browser grant exists on it). The opportunity list/detail pages, and
applicationRepository, now key off this stable id;opportunity_version_idkeeps its originaljob of pinning the exact content shown at application time.
(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 andnavigates to the existing application instead of erroring.
filtering in React after an already-paginated page came back from
search(), so the exactcount 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 theexact 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.
free text — explicitly plain-text matching, not geocoding) and source/lifecycle-status controls
using the repository's existing
sourceKey/lifecycleStatussupport.private_opportunities.source_urlis ordinary mutableowner-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.
window.confirmbefore deleting an application (notes it alsoremoves 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.