From db18df6040fa58d10258770da9718bd41a945580 Mon Sep 17 00:00:00 2001
From: Abdulrahman
Date: Tue, 4 Aug 2026 16:33:10 +0300
Subject: [PATCH 1/8] feat: add opportunity and application data model
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.
---
docs/DATA_MODEL.md | 77 +-
docs/DATA_SOURCES_AND_COMPLIANCE.md | 13 +-
docs/RLS_POLICY_MATRIX.md | 32 +-
.../20260804100000_opportunity_foundation.sql | 935 ++++++++++++++++++
.../070_opportunity_foundation.test.sql | 147 +++
.../080_private_and_applications.test.sql | 287 ++++++
6 files changed, 1443 insertions(+), 48 deletions(-)
create mode 100644 supabase/migrations/20260804100000_opportunity_foundation.sql
create mode 100644 supabase/tests/database/070_opportunity_foundation.test.sql
create mode 100644 supabase/tests/database/080_private_and_applications.test.sql
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index ed5be95..f7695f4 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -184,19 +184,35 @@ versions** carrying the actual historical content. See
[ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md) for the full
reasoning.
+**Implementation status and scope note (Phase 2A.1)**: with exactly one adapter (Greenhouse)
+enabled, cross-source deduplication — `potential_duplicate_links`, the strong/weak-match linking
+rule, `link_confidence`, `opportunities.merged_into_opportunity_id` — is **not implemented**. There
+is no second source that could ever produce a match, so that code would be unexercised and
+untestable. `source_listings.opportunity_id` is therefore `not null` (one listing maps to exactly
+one opportunity today, a simplification of the table below). Revisit when a second adapter is
+proposed, per ADR-010's own scope note. All writes to this domain go through three narrow,
+trusted-only Postgres functions (`begin_ingestion_run`, `apply_source_listing`,
+`finalize_ingestion_run`) rather than direct table grants — see
+[RLS_POLICY_MATRIX.md](RLS_POLICY_MATRIX.md).
+
| Table | Key fields | Notes |
|---|---|---|
-| `sources` | `id`, `name`, `adapter_key`, `base_url`, `legal_basis_notes`, `rate_limit_config`, `enabled` | Registry of configured adapters; see [ADR-004](adr/ADR-004-source-adapter-architecture.md) |
-| `source_listings` | `id`, `source_id`, `external_id`, `canonical_source_url`, `first_seen_at`, `last_seen_at`, `status` (`active`\|`removed`), `opportunity_id` (FK, nullable until linked), `link_confidence` (`exact`\|`strong_deterministic`\|`needs_review`\|`unlinked`) | One row per (source, external_id) — this is what an adapter actually observed. Never holds descriptive content itself; content lives in `opportunity_versions` |
-| `opportunities` | `id`, `status` (`active`\|`removed`\|`needs_review`\|`merged_duplicate`), `first_discovered_at`, `last_checked_at`, `current_version_id`, `merged_into_opportunity_id` (nullable, set only when `status = merged_duplicate`) | The **canonical, real-world opportunity**. Holds identity, lifecycle, and timestamps only — no descriptive content. One or more `source_listings` may point to the same `opportunities` row |
-| `opportunity_versions` | see full field list below | Immutable historical snapshot. Tied to the specific `source_listing_id` whose capture produced it, and to the canonical `opportunity_id` |
-| `potential_duplicate_links` | `id`, `opportunity_id_a`, `opportunity_id_b`, `match_basis_jsonb`, `match_confidence`, `status` (`pending_review`\|`confirmed_duplicate`\|`rejected`), `created_at`, `reviewed_at` | Uncertain cross-source matches that were **not** auto-merged; see dedup rules below |
-| `ingestion_runs` | `id`, `source_id`, `started_at`, `finished_at`, `status`, `records_found`, `records_new`, `records_updated`, `records_removed`, `error_summary` | Operational log, also drives [OBSERVABILITY.md](OBSERVABILITY.md) |
+| `sources` | `id`, `source_key` (unique, e.g. `greenhouse:helsing`), `adapter_kind` (non-unique, e.g. `greenhouse`), `display_name`, `base_url`, `enabled` | Registry of configured, reviewed adapters/boards; see [ADR-004](adr/ADR-004-source-adapter-architecture.md). `source_key` and `adapter_kind` are deliberately distinct: a single adapter serves multiple boards, each its own `source_key` |
+| `source_listings` | `id`, `source_id`, `external_id`, `canonical_source_url`, `application_url`, `first_seen_at`, `last_seen_at`, `status` (`active`\|`removed`), `absence_count`, `opportunity_id` (FK, not null) | One row per (source, external_id) — this is what an adapter actually observed. Never holds descriptive content itself; content lives in `opportunity_versions` |
+| `opportunities` | `id`, `status` (`active`\|`stale`\|`closed`\|`unknown`), `first_discovered_at`, `last_checked_at`, `current_version_id` | The **canonical, real-world opportunity**. Holds identity, lifecycle, and timestamps only — no descriptive content |
+| `opportunity_versions` | see full field list below | Immutable historical snapshot. Tied to the specific `source_listing_id` whose capture produced it, and to the canonical `opportunity_id`. Insert-only at the grant level for every role, including service_role |
+| `ingestion_runs` | `id`, `source_id`, `started_at`, `finished_at`, `status` (`running`\|`complete`\|`partial`\|`failed`), `completeness` (`complete`\|`partial`, null while running/failed), `dry_run`, `records_found`, `records_new`, `records_updated`, `records_closed`, `error_count`, `error_summary` (jsonb, capped) | Operational log, one row per CLI invocation |
+| `opportunity_search` (view) | `opportunity_id`, `opportunity_version_id`, `lifecycle_status`, `first_discovered_at`, `last_checked_at`, `source_id`, `source_key`, `source_display_name`, `source_listing_id`, `canonical_source_url`, `title`, `organization`, `description`, `location_text`, `country`, `region`, `city`, `opportunity_kind`, `employment_type`, `remote_mode`, `posted_at`, `application_deadline`, `application_url`, `version_captured_at` | Read model for browsing: one row per opportunity, joined to its current version and provenance. Browser SELECT-only |
-### `opportunity_versions` — complete field list
+### `opportunity_versions` — implemented field list
Every mutable, historically-meaningful fact about a posting lives here, never only on the stable
-`opportunities` row:
+`opportunities` row. Phase 2A.1 implements a practical subset of the originally sketched field
+list — no `category`/`subtype`/career-or-development-specific JSONB (that taxonomy exists to drive
+Ranking 1/2, out of scope here — see [OPPORTUNITY_TAXONOMY.md](OPPORTUNITY_TAXONOMY.md)), no
+separate `sanitized_display_content` (the single `description` field is already the sanitized,
+render-safe text), no `responsibilities`/`required_qualifications`/`preferred_qualifications` split
+(folded into `description`), no `parser_version`/`normalizer_version`:
| Field | Purpose |
|---|---|
@@ -205,30 +221,18 @@ Every mutable, historically-meaningful fact about a posting lives here, never on
| `source_listing_id` | Which source listing's capture produced this version (provenance) |
| `version_number` | Sequential per opportunity |
| `captured_at` | When this snapshot was taken |
-| `title` | |
-| `organization` | Company/institution name |
-| `description` | Normalized, sanitized plain-text/limited-markup body |
-| `responsibilities` | Structured or free-text, source-dependent |
-| `required_qualifications` | |
-| `preferred_qualifications` | |
-| `location` | |
-| `remote_status` | `onsite` \| `hybrid` \| `remote` |
-| `deadline` | Nullable — many postings don't state one |
+| `title`, `organization`, `description` | Sanitized plain text |
+| `location_text`, `country`, `region`, `city` | |
+| `remote_mode` | `onsite` \| `hybrid` \| `remote` \| `unknown` |
+| `opportunity_kind` | Practical trimmed enum: `internship`, `working_student`, `graduate_program`, `entry_level`, `research_assistant`, `phd`, `scholarship`, `hackathon`, `fellowship`, `other` — shared with `private_opportunities` |
+| `employment_type` | `full_time`, `part_time`, `contract`, `temporary`, `internship`, `volunteer`, `other` — shared with `private_opportunities` |
| `application_url` | The URL to actually apply, which may differ from `source_listings.canonical_source_url` |
-| `category` | `career` \| `development` — versioned because a posting's classification can be corrected between captures |
-| `subtype` | e.g. `internship`, `hackathon` — versioned for the same reason |
-| `career_specific_fields` | JSONB: employment type, duration, compensation notes, etc. — only populated when `category = career` |
-| `development_specific_fields` | JSONB: cost, time commitment, produces-artifact flag, event dates, etc. — only populated when `category = development` |
-| `sanitized_display_content` | The exact sanitized content the frontend is permitted to render (see [SECURITY_AND_PRIVACY.md](SECURITY_AND_PRIVACY.md)); kept distinct from `description` so rendering and scoring can evolve independently |
-| `source_metadata` | JSONB: source-specific fields not mapped into the common schema, retained for future remapping/debugging — sanitized, never raw HTML (see §"Raw external-content retention" below) |
+| `posted_at`, `application_deadline`, `source_updated_at` | Nullable — not every source states these |
| `content_hash` | Used for idempotent change detection |
-| `parser_version` | Which adapter/normalizer code version produced this version |
-| `normalizer_version` | Same purpose, for the shared normalization step, when it differs from the adapter's own version |
+| `source_metadata` | JSONB, bounded (≤ 8 KB): source-specific fields not mapped into the common schema — sanitized, never raw HTML (see §"Raw external-content retention" below) |
-`opportunities.current_version_id` always points at the version currently treated as the
-"live" content for that opportunity (ordinarily the latest by `captured_at`, but see the
-duplicate-resolution note in [ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md)
-for how this is chosen when multiple source listings feed one opportunity).
+`opportunities.current_version_id` always points at the version currently treated as the "live"
+content for that opportunity — the latest by `version_number`, set by `apply_source_listing`.
## Scoring domain (shared source data, but scores are computed per user where applicable)
@@ -252,13 +256,18 @@ distinct, timestamped event, not a mutation of the number the user already saw.
## User activity domain (user-owned, RLS-protected)
+**Implementation status (Phase 2A.1)**: `user_opportunity_state`, `applications`, `interview_prep_notes`,
+and `private_opportunities` are implemented. `tasks` remains a logical target only — MVP_SCOPE.md's
+"simple tasks associated with opportunities" is satisfied at Phase 2A.1 by `applications.next_action`/
+`next_action_due_at` directly; a standalone `tasks` table (including deadline-derived tasks that exist
+before any application) is deferred to a later pass.
+
| Table | Key fields | Notes |
|---|---|---|
-| `saved_opportunities` | `id`, `user_id`, `opportunity_id`, `saved_opportunity_version_id`, `notes`, `saved_at` | Snapshot pinning: even if the opportunity gets new versions later, this remembers which version — and, transitively via that version's `source_listing_id`, which source content — the user actually saved and reacted to |
-| `applications` | `id`, `user_id`, `saved_opportunity_id` (nullable FK to `saved_opportunities`, set for a shared/ingested opportunity), `private_opportunity_id` (nullable FK to `private_opportunities`, set for a manually entered one — exactly one of the two is set), `private_opportunity_snapshot_jsonb` (populated only when `private_opportunity_id` is set — see note below), `status` (`preparing`\|`applied`\|`awaiting_response`\|`interview_scheduled`\|`interview_complete`\|`offer`\|`accepted`\|`rejected`\|`withdrawn`\|`closed`), `applied_at`, `status_updated_at`, `next_action` (nullable), `next_action_due_at` (nullable), `notes`, `contact_note` (nullable), `resume_id_used` | Current status plus timestamps only — no event-sourced status-history table at MVP (see [MVP_SCOPE.md](MVP_SCOPE.md#current-product-mode-and-engineering-priority)); "saved" is a state prior to and outside this enum, held on `saved_opportunities`/`private_opportunities` |
-| `tasks` | `id`, `user_id`, `application_id` (nullable), `opportunity_id` (nullable), `title`, `due_date`, `status` (`open`\|`done`), `origin` (`manual`\|`suggested`) | Deadline-derived tasks reference the opportunity directly even before an application exists |
-| `interview_prep_notes` | `id`, `application_id`, `content`, `created_at` | Free-text notes tied to the application — likely questions, topics to revise, recruiter notes, post-interview reflections all fit in `content`; no structured sub-fields at MVP |
-| `private_opportunities` | `id`, `user_id`, `source_url`, `title`, `organization_name`, `location_text`, `opportunity_kind`, `employment_type`, `remote_mode`, `description_text`, `posted_at`, `application_deadline`, `application_url`, `dismissed_at`, `promoted_to_opportunity_id` (nullable, trusted-write only), `created_at`, `updated_at`, `last_confirmed_at` | **New (Phase 2A, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, structurally and permission-wise separate from the shared identity domain above — not written through the ingestion/dedup pipeline, not versioned like `opportunity_versions` (ordinary mutable CRUD, like `work_experience`), private by default. `promoted_to_opportunity_id` is a future, trusted-only, additive link to a canonical shared opportunity if one is later discovered by an adapter; no promotion logic exists yet. Exact column types/constraints are finalized at Phase 2A.1 implementation time |
+| `user_opportunity_state` | `id`, `user_id`, `opportunity_id`, `saved_at` (nullable), `saved_opportunity_version_id` (nullable, set exactly when `saved_at` is), `hidden_at` (nullable), `notes`, `created_at`, `updated_at` | **Implemented name for what an earlier draft of this document called `saved_opportunities`** — resolved to the name [ADR-018](adr/ADR-018-private-manual-opportunities.md) itself uses when contrasting this table with `private_opportunities.dismissed_at`. One row per `(user_id, opportunity_id)`; a "neither saved nor hidden" state has no row at all (unsave+unhide deletes it). Applies only to shared `opportunities` — a private manual opportunity's existence already means it's saved, and `dismissed_at` is its hide equivalent. Snapshot pinning: `saved_opportunity_version_id` remembers which version the user actually saved and reacted to, even after the opportunity gets new versions later |
+| `applications` | `id`, `user_id`, `opportunity_version_id` (nullable FK to `opportunity_versions`, set and pinned for a shared/ingested opportunity), `private_opportunity_id` (nullable FK to `private_opportunities`, `on delete set null`, set for a manually entered one), `private_opportunity_snapshot` (jsonb, populated only on the manual path — see note below), `status` (`preparing`\|`applied`\|`awaiting_response`\|`interview_scheduled`\|`interview_complete`\|`offer`\|`accepted`\|`rejected`\|`withdrawn`\|`closed`), `applied_at`, `status_updated_at`, `next_action` (nullable), `next_action_due_at` (nullable), `notes`, `contact_note` (nullable) | Current status plus timestamps only — no event-sourced status-history table at MVP (see [MVP_SCOPE.md](MVP_SCOPE.md#current-product-mode-and-engineering-priority)). Exactly one of `opportunity_version_id`/`private_opportunity_snapshot` is set, enforced by both a check constraint and a trigger; the browser has no update grant on either, or on `user_id`/`created_at`, so the pinned source is immutable after creation. `contact_note` holds general recruiter/contact notes; interview-specific observations live on `interview_prep_notes` instead, to avoid two fields with the same purpose. `status_updated_at` advances only when `status` actually changes; an ordinary note edit only advances `updated_at` |
+| `interview_prep_notes` | `id`, `application_id` (unique — one row per application), `user_id` (denormalized, server-derived from the application's owner, never client-supplied), `responsibilities_to_discuss`, `required_technologies`, `topics_to_revise`, `likely_questions`, `questions_to_ask`, `interview_date`, `interview_format`, `reflections`, `created_at`, `updated_at` | Plain-text fields, not a single freeform blob, so the pinned listing snapshot and the prep notes can be shown side by side without parsing. No recruiter/contact-note field here — that lives on `applications.contact_note` |
+| `private_opportunities` | `id`, `user_id`, `source_url`, `title`, `organization_name`, `location_text`, `opportunity_kind`, `employment_type`, `remote_mode`, `description_text`, `posted_at`, `application_deadline`, `application_url`, `dismissed_at`, `promoted_to_opportunity_id` (nullable, trusted-write only), `created_at`, `updated_at`, `last_confirmed_at` | **Implemented (Phase 2A.1, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, structurally and permission-wise separate from the shared identity domain above — not written through the ingestion/dedup pipeline, not versioned like `opportunity_versions` (ordinary mutable CRUD, like `work_experience`), private by default. `opportunity_kind`/`employment_type`/`remote_mode` use the same practical enums as `opportunity_versions` (see the note below). `promoted_to_opportunity_id` is a future, trusted-only, additive link to a canonical shared opportunity if one is later discovered by an adapter; no promotion logic exists yet |
## Raw external-content retention (debug-only, not a core table)
diff --git a/docs/DATA_SOURCES_AND_COMPLIANCE.md b/docs/DATA_SOURCES_AND_COMPLIANCE.md
index e9201a0..ae1b1e1 100644
--- a/docs/DATA_SOURCES_AND_COMPLIANCE.md
+++ b/docs/DATA_SOURCES_AND_COMPLIANCE.md
@@ -254,8 +254,15 @@ decision. None of them are implemented as adapters yet — Bosch and Continental
highest-value target once the SmartRecruiters clarification (§3) is resolved; every other watchlist
employer is manual-import-only pending its own custom-site terms review if one is ever undertaken.
-## 7. Source registry (implementation-time; none configured yet)
+## 7. Source registry (implementation-time)
-| Source | Category | Legal basis confirmed | Rate limit | Status |
+Populated at Phase 2A.1 implementation time. All three boards below were reviewed during the
+Phase 2A.0 research pass recorded in §3 above; enabling them for real ingestion in Phase 2A.1 is
+the repository owner's explicit, direct authorization (per §5's checklist item 13 / this document's
+§3 Greenhouse entry), not an automated or default-on decision.
+
+| `source_key` | Category | Legal basis confirmed | Rate limit | Status |
|---|---|---|---|---|
-| *(none configured yet — this table is populated at Phase 2A.1 implementation time, one row per registered `sources` entry, `enabled = false` until manually verified)* | | | | |
+| `greenhouse:helsing` | Greenhouse Job Board API | See §3 Greenhouse entry above | No documented limit; CLI applies a conservative self-imposed interval/timeout regardless | `enabled = true` |
+| `greenhouse:marvelfusion` | Greenhouse Job Board API | See §3 Greenhouse entry above | Same as above | `enabled = true` |
+| `greenhouse:konux` | Greenhouse Job Board API | See §3 Greenhouse entry above | Same as above | `enabled = true` |
diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md
index 650b780..803661a 100644
--- a/docs/RLS_POLICY_MATRIX.md
+++ b/docs/RLS_POLICY_MATRIX.md
@@ -97,25 +97,35 @@ Phase 1A migration; resume/suggestion design must be revisited with field-level
## Opportunity identity and content domain (shared/global)
+**Status (Phase 2A.1)**: implemented as described below, with all writes going through three
+narrow `SECURITY DEFINER` functions (`begin_ingestion_run`, `apply_source_listing`,
+`finalize_ingestion_run`) instead of direct table grants — none of the three are granted `EXECUTE`
+to `anon` or `authenticated`, only to `service_role`. `potential_duplicate_links` and the
+merge-confirmation function are not implemented (see the Phase 2A.1 scope note in
+[DATA_MODEL.md](DATA_MODEL.md#opportunity-identity-domain-sharedglobal-service-role-write)); revisit
+when a second adapter is added.
+
| Table | Owner / data type | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation test | Sensitivity |
|---|---|---|---|---|---|---|---|---|---|---|
| `sources` | Shared adapter/source registry | All authenticated users | No | No | No | Full | None (shared read, service-role write) | No | Any authenticated user can read; no authenticated user can write | Low |
-| `source_listings` | Shared — one row per (source, external_id) observation | All authenticated users | No | No | No | Full | None | No | Same as above | Low |
-| `opportunities` | Shared — canonical, real-world opportunity identity/status | All authenticated users | No | **No direct grant.** The one legitimate user-triggered change (confirming a duplicate merge) goes through the `confirm_potential_duplicate(...)` `SECURITY DEFINER` function described in [ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md), which is the only path allowed to set `status = merged_duplicate` / `merged_into_opportunity_id` | No | Full | None (shared read; writes via service-role or the one narrow function) | No | Any authenticated user can read; no authenticated user can perform a raw UPDATE/INSERT/DELETE; the merge-confirmation function only ever touches the exact row pair it's called with | Low (identity/status metadata only — no descriptive content lives here) |
-| `opportunity_versions` | Shared — immutable content snapshots | All authenticated users | No | No (never — immutability is enforced at the RLS/grant level, not just by convention; see [ADR-006](adr/ADR-006-posting-version-history.md)) | No | Insert-only (no service-role UPDATE/DELETE either, by policy, to make the immutability guarantee structural rather than merely a code convention) | None (shared read; append-only writes via service-role) | No | Any authenticated user can read; no role — including service-role — can UPDATE or DELETE an existing row; new content only ever arrives as a new row | Low–Medium (contains sanitized third-party posting text) |
-| `potential_duplicate_links` | Shared — pending/resolved duplicate-review flags | All authenticated users | No (created only by the ingestion/dedup step) | **No direct grant.** Confirm/reject goes through the same `confirm_potential_duplicate(...)` / `reject_potential_duplicate(...)` `SECURITY DEFINER` functions, which validate the row exists, is `pending_review`, and belongs to a legitimate pair before changing its status | No | Full | None (shared read; controlled write via function) | No | Any authenticated user can read the queue; no authenticated user can set `status` via a raw UPDATE — only via the review function, and only to `confirmed_duplicate`/`rejected` | Low |
-| `career_market_scores` | Shared — Ranking 1, not user-specific | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low |
-| `ingestion_runs` | Shared — operational log | All authenticated users (surfaces staleness/duplicate-queue depth per [OBSERVABILITY.md](OBSERVABILITY.md)) | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write; `error_summary` content is checked (by convention + a test) to never contain resume text or profile field values | Low (must never contain personal data — see [SECURITY_AND_PRIVACY.md](SECURITY_AND_PRIVACY.md)) |
-| `market_skill_mentions` | Shared — aggregate market-intelligence snapshot | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low (aggregate/anonymized by construction) |
+| `source_listings` | Shared — one row per (source, external_id) observation | All authenticated users | No | No | No | Full (in practice, only ever written via `apply_source_listing`/`finalize_ingestion_run`) | None | No | Any authenticated user can read; no authenticated user can write | Low |
+| `opportunities` | Shared — canonical, real-world opportunity identity/status | All authenticated users | No | **No direct grant of any kind** — every write goes through `apply_source_listing`/`finalize_ingestion_run` | No | Full (same caveat as above) | None (shared read; writes via the trusted RPCs only) | No | Any authenticated user can read; no authenticated user can perform a raw UPDATE/INSERT/DELETE | Low (identity/status metadata only — no descriptive content lives here) |
+| `opportunity_versions` | Shared — immutable content snapshots | All authenticated users | Insert-only, and only reachable via `apply_source_listing` (no direct `authenticated` grant) | No (never — immutability is enforced at the grant level, not just by convention; see [ADR-006](adr/ADR-006-posting-version-history.md)) | No | Insert-only (no service-role UPDATE/DELETE either, by policy, to make the immutability guarantee structural rather than merely a code convention) | None (shared read; append-only writes via `apply_source_listing`) | No | Any authenticated user can read; no role — including service-role — can UPDATE or DELETE an existing row; new content only ever arrives as a new row | Low–Medium (contains sanitized third-party posting text) |
+| `ingestion_runs` | Shared — operational log | All authenticated users | No | No | No | Insert/update via `begin_ingestion_run`/`finalize_ingestion_run` only | None | No | Any authenticated user can read; no authenticated user can write; `error_summary` content is checked (by convention + a test) to never contain resume text or profile field values | Low (must never contain personal data — see [SECURITY_AND_PRIVACY.md](SECURITY_AND_PRIVACY.md)) |
+| `opportunity_search` (view) | Shared read model: current-version projection joined to source provenance | All authenticated users | N/A (view) | N/A | N/A | Full (direct table access) | None (`security_invoker`; relies on the base tables' own read-only grants) | No | Any authenticated user can read; anon is denied at the grant level | Low |
+| `potential_duplicate_links` | **Not yet implemented** (Phase 2A.1 scope note above) — proposed shape retained for when a second adapter is added: shared, pending/resolved duplicate-review flags | All authenticated users | No (created only by the ingestion/dedup step) | **No direct grant.** Confirm/reject would go through `confirm_potential_duplicate(...)` / `reject_potential_duplicate(...)` `SECURITY DEFINER` functions, which validate the row exists, is `pending_review`, and belongs to a legitimate pair before changing its status | No | Full | None (shared read; controlled write via function) | No | Any authenticated user can read the queue; no authenticated user can set `status` via a raw UPDATE — only via the review function, and only to `confirmed_duplicate`/`rejected` | Low |
+| `career_market_scores` | **Not yet implemented** (Phase 2A.2) — Shared, Ranking 1, not user-specific | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low |
+| `market_skill_mentions` | **Not yet implemented** (Phase 6) — Shared, aggregate market-intelligence snapshot | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low (aggregate/anonymized by construction) |
## User activity domain (user-owned)
| Table | Owner / data type | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation test | Sensitivity |
|---|---|---|---|---|---|---|---|---|---|---|
-| `saved_opportunities`, `applications`, `tasks` | One row per user action, owned directly via `user_id` | Own rows only | Own rows only | Own rows only | Own rows only | Full (admin/export/account-deletion) | `user_id = auth.uid()` | No | User A cannot SELECT/INSERT/UPDATE/DELETE any row owned by user B; full CRUD on own rows | High (reveals job-search targets, strategy, and status — treat application/task content as sensitive career-strategy data) |
-| `interview_prep_notes` | Owned transitively via `application_id → applications.user_id` (a denormalized `user_id` column on this table is recommended purely to keep the RLS predicate simple and avoid a subquery on every policy check) | Own rows only | Own rows only | Own rows only | Own rows only | Full | `user_id = auth.uid()` (denormalized) or `application_id IN (SELECT id FROM applications WHERE user_id = auth.uid())` if not denormalized | No | Same isolation test pattern as above | High (may contain candid personal notes on interview performance/strategy) |
-| `career_fit_scores`, `development_scores` | User-specific scores against a shared opportunity version | Own rows only | No (written only by the scoring job) | No | No | Full | `user_id = auth.uid()` | No | User A cannot SELECT any score row belonging to user B; user A cannot write any score row via the browser role, including their own | High (`eligibility_status` in particular can reveal immigration/visa-driven exclusion — treat as sensitive personal-attribute data) |
-| `private_opportunities` | **New (Phase 2A, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, owned directly via `user_id`; structurally and permission-wise unrelated to the shared opportunity-identity domain below | Own rows only | Own rows only | Own rows only, content columns only | Own rows only | Full for maintenance/export/account deletion; the browser has **no** grant to write `promoted_to_opportunity_id` under any circumstance — that column is trusted-write only, reserved for a future, not-yet-built promotion mechanism | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK`, following the `work_experience` template exactly | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert denial; ownership-rewrite denial; browser cannot write `promoted_to_opportunity_id`, `created_at`, or `updated_at` | High (reveals job-search targets and interests, same sensitivity class as `saved_opportunities`) |
+| `user_opportunity_state` | Save/hide state for a shared opportunity, one row per `(user_id, opportunity_id)` — **implemented name for what an earlier draft of this document called `saved_opportunities`**, per the naming ADR-018 itself uses | Own rows only | Own rows only | Own rows only, excluding `user_id`/`opportunity_id` | Own rows only | Full (admin/export/account-deletion) | `(select auth.uid()) = user_id` | No | User A cannot SELECT/INSERT/UPDATE/DELETE any row owned by user B; full CRUD on own rows; a trigger rejects a `saved_opportunity_version_id` that doesn't belong to the given `opportunity_id` | High (reveals job-search targets and interests) |
+| `applications` | One row per application, owned directly via `user_id` | Own rows only | Own rows only, excluding `private_opportunity_snapshot`/`status_updated_at`/timestamps (server-derived) | Own rows only, excluding `opportunity_version_id`, `private_opportunity_id`, `private_opportunity_snapshot`, `user_id`, and timestamps | Own rows only | Full (admin/export/account-deletion) | `(select auth.uid()) = user_id` | No | User A cannot SELECT/INSERT/UPDATE/DELETE any row owned by user B; the pinned shared version and the manual snapshot are immutable after creation (grant-level, plus a trigger for `private_opportunity_id` re-pointing); exactly-one-source and valid-status are enforced by both a check constraint and a trigger | High (reveals job-search targets, strategy, and status — treat as sensitive career-strategy data) |
+| `interview_prep_notes` | One row per application; `user_id` is denormalized and always server-derived from the referenced application's owner, never client-supplied | Own rows only | Own rows only (browser has no grant to set `user_id` at all) | Own rows only, excluding `user_id`/`application_id`/timestamps | Own rows only | Full | `(select auth.uid()) = user_id` | No | User A cannot SELECT any row belonging to user B; attaching notes to user B's application fails because RLS hides that application from user A's own ownership-lookup subquery | High (may contain candid personal notes on interview performance/strategy) |
+| `private_opportunities` | **Implemented (Phase 2A.1, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, owned directly via `user_id`; structurally and permission-wise unrelated to the shared opportunity-identity domain above | Own rows only | Own rows only | Own rows only, content columns only (excludes `source_url`, which is preserved verbatim as provenance) | Own rows only | Full for maintenance/export/account deletion; the browser has **no** grant to write `promoted_to_opportunity_id` under any circumstance — that column is trusted-write only, reserved for a future, not-yet-built promotion mechanism | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK`, following the `work_experience` template exactly | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert denial; browser cannot write `promoted_to_opportunity_id`, `source_url` (on update), `created_at`, or `updated_at`; deleting a private opportunity referenced by an application sets `applications.private_opportunity_id` to null (`on delete set null`) while the application's own snapshot survives | High (reveals job-search targets and interests) |
+| `career_fit_scores`, `development_scores` | **Not yet implemented** (Phase 3) — user-specific scores against a shared opportunity version | Own rows only | No (written only by the scoring job) | No | No | Full | `(select auth.uid()) = user_id` | No | User A cannot SELECT any score row belonging to user B; user A cannot write any score row via the browser role, including their own | High (`eligibility_status` in particular can reveal immigration/visa-driven exclusion — treat as sensitive personal-attribute data) |
## Storage buckets
diff --git a/supabase/migrations/20260804100000_opportunity_foundation.sql b/supabase/migrations/20260804100000_opportunity_foundation.sql
new file mode 100644
index 0000000..242de92
--- /dev/null
+++ b/supabase/migrations/20260804100000_opportunity_foundation.sql
@@ -0,0 +1,935 @@
+-- Phase 2A.1: shared opportunity identity/version schema, private manual
+-- opportunities, save/hide state, application tracking, and interview-prep
+-- notes. See docs/adr/ADR-004, ADR-006, ADR-010, ADR-016, ADR-017, ADR-018
+-- and docs/DATA_MODEL.md for the design this migration implements.
+--
+-- Scope note (Phase 2A.1): with exactly one adapter (Greenhouse) enabled,
+-- cross-source deduplication (ADR-010's strong/weak-match linking and
+-- potential_duplicate_links) is not implemented here -- there is no second
+-- source that could ever produce a match, so the code would be unexercised
+-- and untestable. source_listings.opportunity_id is therefore `not null`
+-- (one listing maps to exactly one opportunity today). Revisit when a second
+-- adapter is proposed, per ADR-010's own scope note.
+
+-- ---------------------------------------------------------------------------
+-- Shared, service-role-write opportunity domain
+-- ---------------------------------------------------------------------------
+
+create table public.sources (
+ id uuid primary key default gen_random_uuid(),
+ source_key text not null unique,
+ adapter_kind text not null,
+ display_name text not null,
+ base_url text not null,
+ enabled boolean not null default false,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint sources_source_key_check check (btrim(source_key) <> '' and char_length(source_key) <= 100),
+ constraint sources_adapter_kind_check check (adapter_kind in ('greenhouse')),
+ constraint sources_display_name_check check (btrim(display_name) <> '' and char_length(display_name) <= 200),
+ constraint sources_base_url_check check (base_url ~ '^https://' and char_length(base_url) <= 500)
+);
+
+comment on table public.sources is
+ 'Registry of configured, reviewed ingestion sources (one row per adapter+board). Browser read-only.';
+comment on column public.sources.source_key is
+ 'Unique board identity, e.g. greenhouse:helsing -- distinct from adapter_kind, which is non-unique.';
+
+create table public.opportunities (
+ id uuid primary key default gen_random_uuid(),
+ status text not null default 'active',
+ first_discovered_at timestamptz not null default now(),
+ last_checked_at timestamptz not null default now(),
+ current_version_id uuid,
+ constraint opportunities_status_check check (status in ('active', 'stale', 'closed', 'unknown'))
+);
+
+comment on table public.opportunities is
+ 'Canonical, real-world opportunity identity and lifecycle only -- no descriptive content lives here (see opportunity_versions).';
+
+create table public.source_listings (
+ id uuid primary key default gen_random_uuid(),
+ source_id uuid not null references public.sources (id) on delete cascade,
+ external_id text not null,
+ canonical_source_url text not null,
+ application_url text,
+ first_seen_at timestamptz not null default now(),
+ last_seen_at timestamptz not null default now(),
+ status text not null default 'active',
+ absence_count smallint not null default 0,
+ opportunity_id uuid not null,
+ constraint source_listings_source_external_key unique (source_id, external_id),
+ constraint source_listings_status_check check (status in ('active', 'removed')),
+ constraint source_listings_absence_count_check check (absence_count >= 0),
+ constraint source_listings_canonical_url_check check (char_length(canonical_source_url) <= 2048),
+ constraint source_listings_application_url_check check (application_url is null or char_length(application_url) <= 2048)
+);
+
+comment on table public.source_listings is
+ 'One row per (source, external_id) observation -- what an adapter actually saw. Never holds descriptive content.';
+
+create table public.opportunity_versions (
+ id uuid primary key default gen_random_uuid(),
+ opportunity_id uuid not null references public.opportunities (id) on delete cascade,
+ source_listing_id uuid not null references public.source_listings (id) on delete cascade,
+ version_number integer not null,
+ captured_at timestamptz not null default now(),
+ title text not null,
+ organization text not null,
+ description text not null,
+ location_text text,
+ country text,
+ region text,
+ city text,
+ remote_mode text not null default 'unknown',
+ opportunity_kind text not null,
+ employment_type text not null,
+ application_url text,
+ posted_at timestamptz,
+ application_deadline timestamptz,
+ source_updated_at timestamptz,
+ content_hash text not null,
+ source_metadata jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now(),
+ constraint opportunity_versions_version_number_check check (version_number > 0),
+ constraint opportunity_versions_unique_version unique (opportunity_id, version_number),
+ constraint opportunity_versions_title_check check (btrim(title) <> '' and char_length(title) <= 400),
+ constraint opportunity_versions_organization_check check (btrim(organization) <> '' and char_length(organization) <= 200),
+ constraint opportunity_versions_description_check check (char_length(description) <= 20000),
+ constraint opportunity_versions_location_check check (location_text is null or char_length(location_text) <= 200),
+ constraint opportunity_versions_remote_mode_check check (remote_mode in ('onsite', 'hybrid', 'remote', 'unknown')),
+ constraint opportunity_versions_kind_check check (
+ opportunity_kind in (
+ 'internship', 'working_student', 'graduate_program', 'entry_level',
+ 'research_assistant', 'phd', 'scholarship', 'hackathon', 'fellowship', 'other'
+ )
+ ),
+ constraint opportunity_versions_employment_type_check check (
+ employment_type in ('full_time', 'part_time', 'contract', 'temporary', 'internship', 'volunteer', 'other')
+ ),
+ constraint opportunity_versions_content_hash_check check (btrim(content_hash) <> ''),
+ constraint opportunity_versions_source_metadata_size_check check (octet_length(source_metadata::text) <= 8192)
+);
+
+comment on table public.opportunity_versions is
+ 'Immutable, append-only content snapshot per detected change. Insert-only for every role, including service_role -- never updated or deleted.';
+
+alter table public.opportunities
+ add constraint opportunities_current_version_fk
+ foreign key (current_version_id) references public.opportunity_versions (id);
+
+alter table public.source_listings
+ add constraint source_listings_opportunity_fk
+ foreign key (opportunity_id) references public.opportunities (id) on delete cascade;
+
+create table public.ingestion_runs (
+ id uuid primary key default gen_random_uuid(),
+ source_id uuid not null references public.sources (id) on delete cascade,
+ started_at timestamptz not null default now(),
+ finished_at timestamptz,
+ status text not null default 'running',
+ completeness text,
+ dry_run boolean not null default false,
+ records_found integer not null default 0,
+ records_new integer not null default 0,
+ records_updated integer not null default 0,
+ records_closed integer not null default 0,
+ error_count integer not null default 0,
+ error_summary jsonb not null default '[]'::jsonb,
+ constraint ingestion_runs_status_check check (status in ('running', 'complete', 'partial', 'failed')),
+ constraint ingestion_runs_completeness_check check (completeness is null or completeness in ('complete', 'partial')),
+ constraint ingestion_runs_error_summary_check check (
+ jsonb_typeof(error_summary) = 'array'
+ and jsonb_array_length(error_summary) <= 32
+ and octet_length(error_summary::text) <= 8192
+ )
+);
+
+comment on table public.ingestion_runs is
+ 'Operational log, one row per CLI invocation. error_summary holds IDs/types/messages only, never description text or raw payloads.';
+
+create index source_listings_opportunity_idx on public.source_listings (opportunity_id);
+create index source_listings_source_status_idx on public.source_listings (source_id, status, last_seen_at);
+create index opportunity_versions_opportunity_idx on public.opportunity_versions (opportunity_id, version_number desc);
+create index opportunities_status_idx on public.opportunities (status);
+create index ingestion_runs_source_idx on public.ingestion_runs (source_id, started_at desc);
+
+-- Read model for browsing: one row per opportunity, joined to its current
+-- version and provenance. Browser SELECT-only; never written directly.
+create view public.opportunity_search
+with (security_invoker = true) as
+select
+ o.id as opportunity_id,
+ o.current_version_id as opportunity_version_id,
+ o.status as lifecycle_status,
+ o.first_discovered_at,
+ o.last_checked_at,
+ s.id as source_id,
+ s.source_key,
+ s.display_name as source_display_name,
+ sl.id as source_listing_id,
+ sl.canonical_source_url,
+ ov.title,
+ ov.organization,
+ ov.description,
+ ov.location_text,
+ ov.country,
+ ov.region,
+ ov.city,
+ ov.opportunity_kind,
+ ov.employment_type,
+ ov.remote_mode,
+ ov.posted_at,
+ ov.application_deadline,
+ ov.application_url,
+ ov.captured_at as version_captured_at
+from public.opportunities o
+join public.opportunity_versions ov on ov.id = o.current_version_id
+join public.source_listings sl on sl.id = ov.source_listing_id
+join public.sources s on s.id = sl.source_id;
+
+comment on view public.opportunity_search is
+ 'Current-version projection of every shared opportunity, for list/detail browsing and search. Read-only.';
+
+-- ---------------------------------------------------------------------------
+-- Trusted ingestion RPCs -- service_role only, never granted to the browser.
+-- ---------------------------------------------------------------------------
+
+create function public.begin_ingestion_run(p_source_key text, p_dry_run boolean default false)
+returns uuid
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_source_id uuid;
+ v_run_id uuid;
+begin
+ select id into v_source_id from public.sources where source_key = p_source_key and enabled = true;
+ if v_source_id is null then
+ raise exception using errcode = 'P0002', message = format('source "%s" is not configured or not enabled', p_source_key);
+ end if;
+
+ insert into public.ingestion_runs (source_id, status, dry_run, started_at)
+ values (v_source_id, 'running', coalesce(p_dry_run, false), clock_timestamp())
+ returning id into v_run_id;
+
+ return v_run_id;
+end;
+$$;
+
+comment on function public.begin_ingestion_run(text, boolean) is
+ 'Trusted-only: opens one ingestion_runs row for a configured, enabled source.';
+
+create function public.apply_source_listing(
+ p_source_key text,
+ p_external_id text,
+ p_canonical_source_url text,
+ p_application_url text,
+ p_title text,
+ p_organization text,
+ p_description text,
+ p_location_text text,
+ p_country text,
+ p_region text,
+ p_city text,
+ p_remote_mode text,
+ p_opportunity_kind text,
+ p_employment_type text,
+ p_posted_at timestamptz,
+ p_application_deadline timestamptz,
+ p_source_updated_at timestamptz,
+ p_content_hash text,
+ p_source_metadata jsonb
+)
+returns table (outcome text, opportunity_id uuid, source_listing_id uuid, opportunity_version_id uuid)
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_source_id uuid;
+ v_listing record;
+ v_opportunity_id uuid;
+ v_listing_id uuid;
+ v_current_hash text;
+ v_next_version_number integer;
+ v_new_version_id uuid;
+begin
+ select id into v_source_id from public.sources where source_key = p_source_key and enabled = true;
+ if v_source_id is null then
+ raise exception using errcode = 'P0002', message = format('source "%s" is not configured or not enabled', p_source_key);
+ end if;
+
+ select sl.id, sl.opportunity_id, o.current_version_id as opp_current_version_id
+ into v_listing
+ from public.source_listings sl
+ join public.opportunities o on o.id = sl.opportunity_id
+ where sl.source_id = v_source_id and sl.external_id = p_external_id
+ for update of sl;
+
+ if not found then
+ insert into public.opportunities (status) values ('active') returning id into v_opportunity_id;
+
+ insert into public.source_listings (
+ source_id, external_id, canonical_source_url, application_url,
+ first_seen_at, last_seen_at, status, absence_count, opportunity_id
+ ) values (
+ v_source_id, p_external_id, p_canonical_source_url, p_application_url,
+ clock_timestamp(), clock_timestamp(), 'active', 0, v_opportunity_id
+ ) returning id into v_listing_id;
+
+ insert into public.opportunity_versions (
+ opportunity_id, source_listing_id, version_number, title, organization, description,
+ location_text, country, region, city, remote_mode, opportunity_kind, employment_type,
+ application_url, posted_at, application_deadline, source_updated_at, content_hash, source_metadata
+ ) values (
+ v_opportunity_id, v_listing_id, 1, p_title, p_organization, p_description,
+ p_location_text, p_country, p_region, p_city, p_remote_mode, p_opportunity_kind, p_employment_type,
+ p_application_url, p_posted_at, p_application_deadline, p_source_updated_at, p_content_hash, p_source_metadata
+ ) returning id into v_new_version_id;
+
+ update public.opportunities set current_version_id = v_new_version_id, last_checked_at = clock_timestamp()
+ where id = v_opportunity_id;
+
+ return query select 'created'::text, v_opportunity_id, v_listing_id, v_new_version_id;
+ return;
+ end if;
+
+ v_opportunity_id := v_listing.opportunity_id;
+
+ select content_hash into v_current_hash
+ from public.opportunity_versions where id = v_listing.opp_current_version_id;
+
+ update public.source_listings
+ set last_seen_at = clock_timestamp(), status = 'active', absence_count = 0
+ where id = v_listing.id;
+
+ update public.opportunities
+ set last_checked_at = clock_timestamp(),
+ status = case when status in ('stale', 'closed', 'unknown') then 'active' else status end
+ where id = v_opportunity_id;
+
+ if v_current_hash is distinct from p_content_hash then
+ select coalesce(max(ov.version_number), 0) + 1 into v_next_version_number
+ from public.opportunity_versions ov where ov.opportunity_id = v_opportunity_id;
+
+ insert into public.opportunity_versions (
+ opportunity_id, source_listing_id, version_number, title, organization, description,
+ location_text, country, region, city, remote_mode, opportunity_kind, employment_type,
+ application_url, posted_at, application_deadline, source_updated_at, content_hash, source_metadata
+ ) values (
+ v_opportunity_id, v_listing.id, v_next_version_number, p_title, p_organization, p_description,
+ p_location_text, p_country, p_region, p_city, p_remote_mode, p_opportunity_kind, p_employment_type,
+ p_application_url, p_posted_at, p_application_deadline, p_source_updated_at, p_content_hash, p_source_metadata
+ ) returning id into v_new_version_id;
+
+ update public.opportunities set current_version_id = v_new_version_id where id = v_opportunity_id;
+
+ return query select 'updated'::text, v_opportunity_id, v_listing.id, v_new_version_id;
+ return;
+ end if;
+
+ return query select 'unchanged'::text, v_opportunity_id, v_listing.id, v_listing.opp_current_version_id;
+end;
+$$;
+
+comment on function public.apply_source_listing(
+ text, text, text, text, text, text, text, text, text, text, text, text, text, text,
+ timestamptz, timestamptz, timestamptz, text, jsonb
+) is
+ 'Trusted-only: atomically resolves/creates one source_listing + opportunity, and creates a new opportunity_version only when content_hash changed. Never partially applied.';
+
+create function public.finalize_ingestion_run(
+ p_run_id uuid,
+ p_status text,
+ p_records_found integer,
+ p_records_new integer,
+ p_records_updated integer,
+ p_error_count integer,
+ p_error_summary jsonb
+)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_source_id uuid;
+ v_started_at timestamptz;
+ v_completeness text;
+ v_closed_count integer := 0;
+begin
+ if p_status not in ('complete', 'partial', 'failed') then
+ raise exception using errcode = '22023', message = 'invalid ingestion run status';
+ end if;
+ v_completeness := case when p_status = 'complete' then 'complete' when p_status = 'partial' then 'partial' else null end;
+
+ update public.ingestion_runs
+ set finished_at = clock_timestamp(), status = p_status, completeness = v_completeness,
+ records_found = coalesce(p_records_found, 0), records_new = coalesce(p_records_new, 0),
+ records_updated = coalesce(p_records_updated, 0), error_count = coalesce(p_error_count, 0),
+ error_summary = coalesce(p_error_summary, '[]'::jsonb)
+ where id = p_run_id
+ returning source_id, started_at into v_source_id, v_started_at;
+
+ if not found then
+ raise exception using errcode = 'P0002', message = 'ingestion run not found';
+ end if;
+
+ -- Conservative closure: only a run known to be complete may close anything,
+ -- and only after two consecutive complete-run absences (see
+ -- docs/INGESTION_ARCHITECTURE.md #8). A partial or failed run closes nothing.
+ if p_status = 'complete' then
+ update public.source_listings
+ set absence_count = absence_count + 1
+ where source_id = v_source_id and status = 'active' and last_seen_at < v_started_at;
+
+ -- Two separate statements, not a chained CTE: a data-modifying CTE and
+ -- its parent statement share one snapshot, so a NOT EXISTS check in the
+ -- parent would not see the CTE's own write. Running them as sequential
+ -- statements lets the second one see the first's committed effect.
+ update public.source_listings
+ set status = 'removed'
+ where source_id = v_source_id and status = 'active' and absence_count >= 2;
+
+ update public.opportunities o
+ set status = 'closed'
+ where o.status <> 'closed'
+ and exists (select 1 from public.source_listings sl where sl.opportunity_id = o.id and sl.source_id = v_source_id)
+ and not exists (
+ select 1 from public.source_listings sl where sl.opportunity_id = o.id and sl.status = 'active'
+ );
+
+ get diagnostics v_closed_count = row_count;
+ update public.ingestion_runs set records_closed = v_closed_count where id = p_run_id;
+ end if;
+end;
+$$;
+
+comment on function public.finalize_ingestion_run(uuid, text, integer, integer, integer, integer, jsonb) is
+ 'Trusted-only: closes out one ingestion_runs row and, only for a complete run, applies the two-consecutive-absence conservative closure rule.';
+
+revoke all on function public.begin_ingestion_run(text, boolean) from public, anon, authenticated;
+revoke all on function public.apply_source_listing(
+ text, text, text, text, text, text, text, text, text, text, text, text, text, text,
+ timestamptz, timestamptz, timestamptz, text, jsonb
+) from public, anon, authenticated;
+revoke all on function public.finalize_ingestion_run(uuid, text, integer, integer, integer, integer, jsonb) from public, anon, authenticated;
+grant execute on function public.begin_ingestion_run(text, boolean) to service_role;
+grant execute on function public.apply_source_listing(
+ text, text, text, text, text, text, text, text, text, text, text, text, text, text,
+ timestamptz, timestamptz, timestamptz, text, jsonb
+) to service_role;
+grant execute on function public.finalize_ingestion_run(uuid, text, integer, integer, integer, integer, jsonb) to service_role;
+
+-- ---------------------------------------------------------------------------
+-- Shared-table grants and RLS: authenticated read-only, service_role full
+-- (except opportunity_versions, which is insert-only for every role).
+-- ---------------------------------------------------------------------------
+
+alter table public.sources enable row level security;
+revoke all on public.sources from anon, authenticated, service_role;
+grant select on public.sources to authenticated;
+grant select, insert, update, delete on public.sources to service_role;
+create policy sources_select_all on public.sources for select to authenticated using (true);
+
+alter table public.opportunities enable row level security;
+revoke all on public.opportunities from anon, authenticated, service_role;
+grant select on public.opportunities to authenticated;
+grant select, insert, update, delete on public.opportunities to service_role;
+create policy opportunities_select_all on public.opportunities for select to authenticated using (true);
+
+alter table public.source_listings enable row level security;
+revoke all on public.source_listings from anon, authenticated, service_role;
+grant select on public.source_listings to authenticated;
+grant select, insert, update, delete on public.source_listings to service_role;
+create policy source_listings_select_all on public.source_listings for select to authenticated using (true);
+
+alter table public.opportunity_versions enable row level security;
+revoke all on public.opportunity_versions from anon, authenticated, service_role;
+grant select on public.opportunity_versions to authenticated;
+grant select, insert on public.opportunity_versions to service_role;
+create policy opportunity_versions_select_all on public.opportunity_versions for select to authenticated using (true);
+
+alter table public.ingestion_runs enable row level security;
+revoke all on public.ingestion_runs from anon, authenticated, service_role;
+grant select on public.ingestion_runs to authenticated;
+grant select, insert, update on public.ingestion_runs to service_role;
+create policy ingestion_runs_select_all on public.ingestion_runs for select to authenticated using (true);
+
+grant select on public.opportunity_search to authenticated, service_role;
+
+-- ---------------------------------------------------------------------------
+-- Private manual opportunities (ADR-018) -- ordinary mutable CRUD, owner-only.
+-- ---------------------------------------------------------------------------
+
+create table public.private_opportunities (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ source_url text not null,
+ title text not null,
+ organization_name text not null,
+ location_text text not null,
+ opportunity_kind text not null,
+ employment_type text not null,
+ remote_mode text not null default 'unknown',
+ description_text text,
+ posted_at timestamptz,
+ application_deadline timestamptz,
+ application_url text,
+ dismissed_at timestamptz,
+ promoted_to_opportunity_id uuid references public.opportunities (id),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ last_confirmed_at timestamptz not null default now(),
+ constraint private_opportunities_user_id_id_key unique (user_id, id),
+ constraint private_opportunities_source_url_check check (source_url ~ '^https://' and char_length(source_url) <= 2048),
+ constraint private_opportunities_title_check check (btrim(title) <> '' and char_length(title) <= 400),
+ constraint private_opportunities_organization_check check (btrim(organization_name) <> '' and char_length(organization_name) <= 200),
+ constraint private_opportunities_location_check check (btrim(location_text) <> '' and char_length(location_text) <= 200),
+ constraint private_opportunities_kind_check check (
+ opportunity_kind in (
+ 'internship', 'working_student', 'graduate_program', 'entry_level',
+ 'research_assistant', 'phd', 'scholarship', 'hackathon', 'fellowship', 'other'
+ )
+ ),
+ constraint private_opportunities_employment_type_check check (
+ employment_type in ('full_time', 'part_time', 'contract', 'temporary', 'internship', 'volunteer', 'other')
+ ),
+ constraint private_opportunities_remote_mode_check check (remote_mode in ('onsite', 'hybrid', 'remote', 'unknown')),
+ constraint private_opportunities_description_check check (description_text is null or char_length(description_text) <= 20000),
+ constraint private_opportunities_application_url_check check (
+ application_url is null or (application_url ~ '^https://' and char_length(application_url) <= 2048)
+ )
+);
+
+comment on table public.private_opportunities is
+ 'Manually entered opportunities: user-owned, private by default, ordinary mutable CRUD -- structurally separate from the shared identity domain (ADR-018).';
+comment on column public.private_opportunities.dismissed_at is
+ 'The private-entry equivalent of "hide". A private row is implicitly saved from the moment it exists -- there is no separate saved state.';
+comment on column public.private_opportunities.promoted_to_opportunity_id is
+ 'Reserved for a future, trusted-only promotion link. No promotion logic exists yet; browser has no write grant on this column.';
+
+create index private_opportunities_user_idx on public.private_opportunities (user_id, created_at desc);
+
+create function public.private_opportunities_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ content_changed boolean;
+begin
+ if tg_op = 'INSERT' then
+ new.created_at := clock_timestamp();
+ new.updated_at := clock_timestamp();
+ new.last_confirmed_at := clock_timestamp();
+ return new;
+ end if;
+
+ new.user_id := old.user_id;
+ new.created_at := old.created_at;
+ new.source_url := old.source_url;
+ new.promoted_to_opportunity_id := old.promoted_to_opportunity_id;
+
+ content_changed :=
+ new.title is distinct from old.title
+ or new.organization_name is distinct from old.organization_name
+ or new.location_text is distinct from old.location_text
+ or new.opportunity_kind is distinct from old.opportunity_kind
+ or new.employment_type is distinct from old.employment_type
+ or new.remote_mode is distinct from old.remote_mode
+ or new.description_text is distinct from old.description_text
+ or new.posted_at is distinct from old.posted_at
+ or new.application_deadline is distinct from old.application_deadline
+ or new.application_url is distinct from old.application_url;
+
+ if content_changed then
+ new.updated_at := clock_timestamp();
+ new.last_confirmed_at := clock_timestamp();
+ elsif new.dismissed_at is distinct from old.dismissed_at then
+ new.updated_at := clock_timestamp();
+ new.last_confirmed_at := old.last_confirmed_at;
+ else
+ new.updated_at := old.updated_at;
+ new.last_confirmed_at := old.last_confirmed_at;
+ end if;
+ return new;
+end;
+$$;
+
+create trigger private_opportunities_before_write
+before insert or update on public.private_opportunities
+for each row execute function public.private_opportunities_before_write();
+
+revoke all on function public.private_opportunities_before_write() from public, anon, authenticated;
+
+alter table public.private_opportunities enable row level security;
+revoke all on public.private_opportunities from anon, authenticated, service_role;
+grant select on public.private_opportunities to authenticated;
+grant insert (
+ user_id, source_url, title, organization_name, location_text, opportunity_kind,
+ employment_type, remote_mode, description_text, posted_at, application_deadline, application_url
+) on public.private_opportunities to authenticated;
+grant update (
+ title, organization_name, location_text, opportunity_kind, employment_type, remote_mode,
+ description_text, posted_at, application_deadline, application_url, dismissed_at
+) on public.private_opportunities to authenticated;
+grant delete on public.private_opportunities to authenticated;
+grant select, insert, update, delete on public.private_opportunities to service_role;
+
+create policy private_opportunities_select_own on public.private_opportunities
+ for select to authenticated using ((select auth.uid()) = user_id);
+create policy private_opportunities_insert_own on public.private_opportunities
+ for insert to authenticated with check ((select auth.uid()) = user_id);
+create policy private_opportunities_update_own on public.private_opportunities
+ for update to authenticated
+ using ((select auth.uid()) = user_id)
+ with check ((select auth.uid()) = user_id);
+create policy private_opportunities_delete_own on public.private_opportunities
+ for delete to authenticated using ((select auth.uid()) = user_id);
+
+-- ---------------------------------------------------------------------------
+-- Save/hide state for shared opportunities only (private rows use dismissed_at).
+-- ---------------------------------------------------------------------------
+
+create table public.user_opportunity_state (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ opportunity_id uuid not null references public.opportunities (id) on delete cascade,
+ saved_at timestamptz,
+ saved_opportunity_version_id uuid references public.opportunity_versions (id),
+ hidden_at timestamptz,
+ notes text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint user_opportunity_state_user_opportunity_key unique (user_id, opportunity_id),
+ constraint user_opportunity_state_saved_pair_check check ((saved_at is null) = (saved_opportunity_version_id is null)),
+ constraint user_opportunity_state_active_check check (saved_at is not null or hidden_at is not null),
+ constraint user_opportunity_state_notes_check check (notes is null or char_length(notes) <= 4000)
+);
+
+comment on table public.user_opportunity_state is
+ 'Save/hide state for shared opportunities, one row per (user, opportunity). A "neither" state has no row -- unsave+unhide deletes it.';
+comment on column public.user_opportunity_state.saved_opportunity_version_id is
+ 'Pins the exact version visible at save time (ADR-006); never silently repointed when the source listing changes later.';
+
+create index user_opportunity_state_user_idx on public.user_opportunity_state (user_id, updated_at desc);
+
+create function public.user_opportunity_state_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if new.saved_opportunity_version_id is not null then
+ if not exists (
+ select 1 from public.opportunity_versions v
+ where v.id = new.saved_opportunity_version_id and v.opportunity_id = new.opportunity_id
+ ) then
+ raise exception using errcode = '23514', message = 'saved_opportunity_version_id must belong to the same opportunity';
+ end if;
+ end if;
+
+ if tg_op = 'INSERT' then
+ new.created_at := clock_timestamp();
+ new.updated_at := clock_timestamp();
+ return new;
+ end if;
+
+ new.user_id := old.user_id;
+ new.opportunity_id := old.opportunity_id;
+ new.created_at := old.created_at;
+ new.updated_at := clock_timestamp();
+ return new;
+end;
+$$;
+
+create trigger user_opportunity_state_before_write
+before insert or update on public.user_opportunity_state
+for each row execute function public.user_opportunity_state_before_write();
+
+revoke all on function public.user_opportunity_state_before_write() from public, anon, authenticated;
+
+alter table public.user_opportunity_state enable row level security;
+revoke all on public.user_opportunity_state from anon, authenticated, service_role;
+grant select on public.user_opportunity_state to authenticated;
+grant insert (
+ user_id, opportunity_id, saved_at, saved_opportunity_version_id, hidden_at, notes
+) on public.user_opportunity_state to authenticated;
+grant update (
+ saved_at, saved_opportunity_version_id, hidden_at, notes
+) on public.user_opportunity_state to authenticated;
+grant delete on public.user_opportunity_state to authenticated;
+grant select, insert, update, delete on public.user_opportunity_state to service_role;
+
+create policy user_opportunity_state_select_own on public.user_opportunity_state
+ for select to authenticated using ((select auth.uid()) = user_id);
+create policy user_opportunity_state_insert_own on public.user_opportunity_state
+ for insert to authenticated with check ((select auth.uid()) = user_id);
+create policy user_opportunity_state_update_own on public.user_opportunity_state
+ for update to authenticated
+ using ((select auth.uid()) = user_id)
+ with check ((select auth.uid()) = user_id);
+create policy user_opportunity_state_delete_own on public.user_opportunity_state
+ for delete to authenticated using ((select auth.uid()) = user_id);
+
+-- ---------------------------------------------------------------------------
+-- Application tracking. Exactly one of a pinned shared version or an
+-- immutable manual snapshot -- never both, never neither.
+-- ---------------------------------------------------------------------------
+
+create table public.applications (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ opportunity_version_id uuid references public.opportunity_versions (id),
+ private_opportunity_id uuid references public.private_opportunities (id) on delete set null,
+ private_opportunity_snapshot jsonb,
+ status text not null default 'preparing',
+ applied_at timestamptz,
+ status_updated_at timestamptz not null default now(),
+ next_action text,
+ next_action_due_at timestamptz,
+ notes text,
+ contact_note text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint applications_source_check check (
+ (opportunity_version_id is not null and private_opportunity_id is null and private_opportunity_snapshot is null)
+ or
+ (opportunity_version_id is null and private_opportunity_snapshot is not null)
+ ),
+ constraint applications_status_check check (
+ status in (
+ 'preparing', 'applied', 'awaiting_response', 'interview_scheduled', 'interview_complete',
+ 'offer', 'accepted', 'rejected', 'withdrawn', 'closed'
+ )
+ ),
+ constraint applications_next_action_check check (next_action is null or char_length(next_action) <= 400),
+ constraint applications_notes_check check (notes is null or char_length(notes) <= 8000),
+ constraint applications_contact_note_check check (contact_note is null or char_length(contact_note) <= 4000)
+);
+
+comment on table public.applications is
+ 'One row per application. opportunity_version_id (shared) or private_opportunity_snapshot (manual) is pinned at creation and never rewritten.';
+comment on column public.applications.contact_note is
+ 'General recruiter/contact notes for this application. Interview-specific observations belong on interview_prep_notes instead.';
+comment on column public.applications.private_opportunity_snapshot is
+ 'Captured once at creation from private_opportunities. Never updated afterward, even if the source private_opportunities row is later edited or deleted.';
+
+create index applications_user_idx on public.applications (user_id, status_updated_at desc);
+create index applications_opportunity_version_idx on public.applications (opportunity_version_id);
+create index applications_private_opportunity_idx on public.applications (private_opportunity_id);
+
+create function public.applications_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_private_owner uuid;
+ v_snapshot jsonb;
+begin
+ if tg_op = 'INSERT' then
+ if (new.opportunity_version_id is not null) = (new.private_opportunity_id is not null) then
+ raise exception using errcode = '23514',
+ message = 'application must reference exactly one of opportunity_version_id or private_opportunity_id';
+ end if;
+
+ if new.private_opportunity_id is not null then
+ select po.user_id, jsonb_build_object(
+ 'source_url', po.source_url,
+ 'title', po.title,
+ 'organization_name', po.organization_name,
+ 'location_text', po.location_text,
+ 'opportunity_kind', po.opportunity_kind,
+ 'employment_type', po.employment_type,
+ 'remote_mode', po.remote_mode,
+ 'description_text', po.description_text,
+ 'posted_at', po.posted_at,
+ 'application_deadline', po.application_deadline,
+ 'application_url', po.application_url
+ ) into v_private_owner, v_snapshot
+ from public.private_opportunities po where po.id = new.private_opportunity_id;
+
+ if v_private_owner is null then
+ raise exception using errcode = 'P0002', message = 'unknown private opportunity';
+ end if;
+ if v_private_owner is distinct from new.user_id then
+ raise exception using errcode = '42501', message = 'cannot create an application for another user''s manual opportunity';
+ end if;
+ new.private_opportunity_snapshot := v_snapshot;
+ else
+ new.private_opportunity_id := null;
+ new.private_opportunity_snapshot := null;
+ end if;
+
+ new.status := coalesce(new.status, 'preparing');
+ new.status_updated_at := clock_timestamp();
+ if new.status <> 'preparing' and new.applied_at is null then
+ new.applied_at := clock_timestamp();
+ end if;
+ new.created_at := clock_timestamp();
+ new.updated_at := clock_timestamp();
+ return new;
+ end if;
+
+ -- UPDATE: pinned source, snapshot, owner, and creation time are immutable.
+ new.user_id := old.user_id;
+ new.created_at := old.created_at;
+ new.opportunity_version_id := old.opportunity_version_id;
+ new.private_opportunity_snapshot := old.private_opportunity_snapshot;
+
+ if new.private_opportunity_id is distinct from old.private_opportunity_id and new.private_opportunity_id is not null then
+ raise exception using errcode = '42501', message = 'cannot repoint or attach a private opportunity after creation';
+ end if;
+
+ if new.status is distinct from old.status then
+ new.status_updated_at := clock_timestamp();
+ if new.status <> 'preparing' and new.applied_at is null then
+ new.applied_at := clock_timestamp();
+ end if;
+ else
+ new.status_updated_at := old.status_updated_at;
+ end if;
+ new.updated_at := clock_timestamp();
+ return new;
+end;
+$$;
+
+create trigger applications_before_write
+before insert or update on public.applications
+for each row execute function public.applications_before_write();
+
+revoke all on function public.applications_before_write() from public, anon, authenticated;
+
+alter table public.applications enable row level security;
+revoke all on public.applications from anon, authenticated, service_role;
+grant select on public.applications to authenticated;
+grant insert (
+ user_id, opportunity_version_id, private_opportunity_id, status, applied_at,
+ next_action, next_action_due_at, notes, contact_note
+) on public.applications to authenticated;
+grant update (
+ status, applied_at, next_action, next_action_due_at, notes, contact_note
+) on public.applications to authenticated;
+grant delete on public.applications to authenticated;
+grant select, insert, update, delete on public.applications to service_role;
+
+create policy applications_select_own on public.applications
+ for select to authenticated using ((select auth.uid()) = user_id);
+create policy applications_insert_own on public.applications
+ for insert to authenticated with check ((select auth.uid()) = user_id);
+create policy applications_update_own on public.applications
+ for update to authenticated
+ using ((select auth.uid()) = user_id)
+ with check ((select auth.uid()) = user_id);
+create policy applications_delete_own on public.applications
+ for delete to authenticated using ((select auth.uid()) = user_id);
+
+-- ---------------------------------------------------------------------------
+-- Interview preparation notes: one row per application.
+-- ---------------------------------------------------------------------------
+
+create table public.interview_prep_notes (
+ id uuid primary key default gen_random_uuid(),
+ application_id uuid not null unique references public.applications (id) on delete cascade,
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ responsibilities_to_discuss text,
+ required_technologies text,
+ topics_to_revise text,
+ likely_questions text,
+ questions_to_ask text,
+ interview_date timestamptz,
+ interview_format text,
+ reflections text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint interview_prep_notes_format_check check (interview_format is null or char_length(interview_format) <= 100),
+ constraint interview_prep_notes_text_lengths_check check (
+ (responsibilities_to_discuss is null or char_length(responsibilities_to_discuss) <= 4000)
+ and (required_technologies is null or char_length(required_technologies) <= 4000)
+ and (topics_to_revise is null or char_length(topics_to_revise) <= 4000)
+ and (likely_questions is null or char_length(likely_questions) <= 4000)
+ and (questions_to_ask is null or char_length(questions_to_ask) <= 4000)
+ and (reflections is null or char_length(reflections) <= 4000)
+ )
+);
+
+comment on table public.interview_prep_notes is
+ 'One editable interview-prep area per application. Recruiter/contact notes live on applications.contact_note instead.';
+
+create function public.interview_prep_notes_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_owner uuid;
+begin
+ if tg_op = 'INSERT' then
+ select user_id into v_owner from public.applications where id = new.application_id;
+ if v_owner is null then
+ raise exception using errcode = 'P0002', message = 'unknown application';
+ end if;
+ new.user_id := v_owner;
+ new.created_at := clock_timestamp();
+ new.updated_at := clock_timestamp();
+ return new;
+ end if;
+
+ new.user_id := old.user_id;
+ new.application_id := old.application_id;
+ new.created_at := old.created_at;
+ new.updated_at := clock_timestamp();
+ return new;
+end;
+$$;
+
+create trigger interview_prep_notes_before_write
+before insert or update on public.interview_prep_notes
+for each row execute function public.interview_prep_notes_before_write();
+
+revoke all on function public.interview_prep_notes_before_write() from public, anon, authenticated;
+
+alter table public.interview_prep_notes enable row level security;
+revoke all on public.interview_prep_notes from anon, authenticated, service_role;
+grant select on public.interview_prep_notes to authenticated;
+grant insert (
+ application_id, responsibilities_to_discuss, required_technologies, topics_to_revise,
+ likely_questions, questions_to_ask, interview_date, interview_format, reflections
+) on public.interview_prep_notes to authenticated;
+grant update (
+ responsibilities_to_discuss, required_technologies, topics_to_revise,
+ likely_questions, questions_to_ask, interview_date, interview_format, reflections
+) on public.interview_prep_notes to authenticated;
+grant delete on public.interview_prep_notes to authenticated;
+grant select, insert, update, delete on public.interview_prep_notes to service_role;
+
+create policy interview_prep_notes_select_own on public.interview_prep_notes
+ for select to authenticated using ((select auth.uid()) = user_id);
+create policy interview_prep_notes_insert_own on public.interview_prep_notes
+ for insert to authenticated with check ((select auth.uid()) = user_id);
+create policy interview_prep_notes_update_own on public.interview_prep_notes
+ for update to authenticated
+ using ((select auth.uid()) = user_id)
+ with check ((select auth.uid()) = user_id);
+create policy interview_prep_notes_delete_own on public.interview_prep_notes
+ for delete to authenticated using ((select auth.uid()) = user_id);
+
+-- ---------------------------------------------------------------------------
+-- Reviewed source registry seed (docs/DATA_SOURCES_AND_COMPLIANCE.md #3).
+-- All three boards were reviewed during Phase 2A.0 research; this task is
+-- the repository owner's explicit authorization to enable them for real
+-- ingestion (INGESTION_ARCHITECTURE.md #10 checklist item 13).
+-- ---------------------------------------------------------------------------
+
+insert into public.sources (source_key, adapter_kind, display_name, base_url, enabled) values
+ ('greenhouse:helsing', 'greenhouse', 'Helsing (Greenhouse)', 'https://boards-api.greenhouse.io/v1/boards/helsing/jobs', true),
+ ('greenhouse:marvelfusion', 'greenhouse', 'Marvel Fusion (Greenhouse)', 'https://boards-api.greenhouse.io/v1/boards/marvelfusion/jobs', true),
+ ('greenhouse:konux', 'greenhouse', 'KONUX (Greenhouse)', 'https://boards-api.greenhouse.io/v1/boards/konux/jobs', true);
diff --git a/supabase/tests/database/070_opportunity_foundation.test.sql b/supabase/tests/database/070_opportunity_foundation.test.sql
new file mode 100644
index 0000000..ad7b0ea
--- /dev/null
+++ b/supabase/tests/database/070_opportunity_foundation.test.sql
@@ -0,0 +1,147 @@
+-- Shared opportunity identity/version domain: structure, grants, RLS, the
+-- trusted ingestion RPCs, immutability, and conservative closure. All
+-- fixture data is synthetic and this transaction is rolled back.
+begin;
+select plan(53);
+
+-- Structure -------------------------------------------------------------
+
+select has_table('public', 'sources', 'sources table exists');
+select has_table('public', 'source_listings', 'source_listings table exists');
+select has_table('public', 'opportunities', 'opportunities table exists');
+select has_table('public', 'opportunity_versions', 'opportunity_versions table exists');
+select has_table('public', 'ingestion_runs', 'ingestion_runs table exists');
+select has_view('public', 'opportunity_search', 'opportunity_search view exists');
+select fk_ok('public', 'source_listings', 'source_id', 'public', 'sources', 'id', 'source_listings.source_id references sources');
+select fk_ok('public', 'source_listings', 'opportunity_id', 'public', 'opportunities', 'id', 'source_listings.opportunity_id references opportunities');
+select fk_ok('public', 'opportunity_versions', 'opportunity_id', 'public', 'opportunities', 'id', 'opportunity_versions.opportunity_id references opportunities');
+select fk_ok('public', 'opportunity_versions', 'source_listing_id', 'public', 'source_listings', 'id', 'opportunity_versions.source_listing_id references source_listings');
+
+select is((select relrowsecurity from pg_class where oid = 'public.sources'::regclass), true, 'RLS enabled on sources');
+select is((select relrowsecurity from pg_class where oid = 'public.source_listings'::regclass), true, 'RLS enabled on source_listings');
+select is((select relrowsecurity from pg_class where oid = 'public.opportunities'::regclass), true, 'RLS enabled on opportunities');
+select is((select relrowsecurity from pg_class where oid = 'public.opportunity_versions'::regclass), true, 'RLS enabled on opportunity_versions');
+select is((select relrowsecurity from pg_class where oid = 'public.ingestion_runs'::regclass), true, 'RLS enabled on ingestion_runs');
+
+-- Grants ------------------------------------------------------------------
+
+select table_privs_are('public', 'sources', 'authenticated', array['SELECT'], 'authenticated has read-only access to sources');
+select table_privs_are('public', 'source_listings', 'authenticated', array['SELECT'], 'authenticated has read-only access to source_listings');
+select table_privs_are('public', 'opportunities', 'authenticated', array['SELECT'], 'authenticated has read-only access to opportunities');
+select table_privs_are('public', 'opportunity_versions', 'authenticated', array['SELECT'], 'authenticated has read-only access to opportunity_versions');
+select table_privs_are('public', 'ingestion_runs', 'authenticated', array['SELECT'], 'authenticated has read-only access to ingestion_runs');
+select table_privs_are('public', 'sources', 'anon', array[]::text[], 'anon has no sources grants');
+select table_privs_are('public', 'opportunities', 'anon', array[]::text[], 'anon has no opportunities grants');
+select table_privs_are('public', 'opportunity_versions', 'service_role', array['SELECT', 'INSERT'], 'opportunity_versions is insert-only, even for service_role -- immutability at the grant level');
+
+-- Seeded reviewed boards ----------------------------------------------------
+
+select is((select count(*)::int from public.sources where enabled = true), 3, 'three reviewed Greenhouse boards are enabled');
+select ok(exists (select 1 from public.sources where source_key = 'greenhouse:helsing'), 'helsing is configured');
+
+-- Trusted RPCs are never reachable from the browser --------------------------
+
+reset role;
+select set_config('request.jwt.claims', json_build_object('sub', '82000000-0000-0000-0000-000000000001', 'role', 'authenticated')::text, true);
+set role authenticated;
+select throws_ok($$ select public.begin_ingestion_run('greenhouse:helsing', false) $$, '42501'::char(5), null, 'browser cannot call begin_ingestion_run');
+select throws_ok(
+ $$ select public.apply_source_listing('greenhouse:helsing','x','https://x','https://x','t','o','d',null,null,null,null,'onsite','internship','internship',null,null,null,'h','{}'::jsonb) $$,
+ '42501'::char(5), null, 'browser cannot call apply_source_listing'
+);
+select throws_ok($$ select public.finalize_ingestion_run(gen_random_uuid(), 'complete', 0, 0, 0, 0, '[]'::jsonb) $$, '42501'::char(5), null, 'browser cannot call finalize_ingestion_run');
+select throws_ok($$ insert into public.opportunities (status) values ('active') $$, '42501'::char(5), null, 'browser cannot insert opportunities directly');
+select throws_ok($$ update public.sources set enabled = false where source_key = 'greenhouse:helsing' $$, '42501'::char(5), null, 'browser cannot write sources directly');
+
+reset role;
+set role anon;
+select throws_ok($$ select 1 from public.sources $$, '42501'::char(5), null, 'anonymous sources SELECT is denied at grants');
+select throws_ok($$ select 1 from public.opportunity_search $$, '42501'::char(5), null, 'anonymous opportunity_search SELECT is denied at grants');
+
+-- Functional: create / unchanged / updated, and view projection -------------
+
+reset role;
+set role service_role;
+select public.begin_ingestion_run('greenhouse:helsing', false) as run_id \gset
+select outcome, opportunity_id, opportunity_version_id from public.apply_source_listing(
+ 'greenhouse:helsing', 'job-1', 'https://job-boards.greenhouse.io/helsing/jobs/1', 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ 'Software Engineer Intern', 'Helsing', 'Description one.', 'Munich, Germany', 'Germany', 'Bavaria', 'Munich',
+ 'onsite', 'internship', 'internship', clock_timestamp(), null, clock_timestamp(), 'hash-1', '{}'::jsonb
+) \gset first_
+
+select is(:'first_outcome'::text, 'created'::text, 'first observation of a listing creates a new opportunity');
+select is((select count(*)::int from public.opportunity_versions where opportunity_id = :'first_opportunity_id'), 1, 'exactly one version exists after the first observation');
+
+select outcome from public.apply_source_listing(
+ 'greenhouse:helsing', 'job-1', 'https://job-boards.greenhouse.io/helsing/jobs/1', 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ 'Software Engineer Intern', 'Helsing', 'Description one.', 'Munich, Germany', 'Germany', 'Bavaria', 'Munich',
+ 'onsite', 'internship', 'internship', clock_timestamp(), null, clock_timestamp(), 'hash-1', '{}'::jsonb
+) \gset rerun_
+select is(:'rerun_outcome'::text, 'unchanged'::text, 'an identical rerun creates no new version');
+select is((select count(*)::int from public.opportunity_versions where opportunity_id = :'first_opportunity_id'), 1, 'version count is unchanged after an identical rerun');
+
+select outcome, opportunity_version_id from public.apply_source_listing(
+ 'greenhouse:helsing', 'job-1', 'https://job-boards.greenhouse.io/helsing/jobs/1', 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ 'Software Engineer Intern', 'Helsing', 'Description one, revised.', 'Munich, Germany', 'Germany', 'Bavaria', 'Munich',
+ 'onsite', 'internship', 'internship', clock_timestamp(), null, clock_timestamp(), 'hash-2', '{}'::jsonb
+) \gset changed_
+select is(:'changed_outcome'::text, 'updated'::text, 'changed content creates exactly one new version');
+select is((select count(*)::int from public.opportunity_versions where opportunity_id = :'first_opportunity_id'), 2, 'version count is two after one content change');
+select is((select current_version_id from public.opportunities where id = :'first_opportunity_id'), :'changed_opportunity_version_id'::uuid, 'current_version_id points at the newest version');
+
+select is(
+ (select title from public.opportunity_search where opportunity_id = :'first_opportunity_id'),
+ 'Software Engineer Intern',
+ 'opportunity_search reflects the current version'
+);
+
+-- Immutability: no role, including service_role, may update or delete a version
+select throws_ok(
+ $$ update public.opportunity_versions set title = 'Hacked' $$,
+ '42501'::char(5), null, 'service_role cannot update an opportunity_version -- insert-only by grant'
+);
+select throws_ok(
+ $$ delete from public.opportunity_versions $$,
+ '42501'::char(5), null, 'service_role cannot delete an opportunity_version -- insert-only by grant'
+);
+
+-- Malformed record isolation: a single bad record is rejected without
+-- corrupting the ones already applied.
+select throws_ok(
+ $$ select public.apply_source_listing('greenhouse:helsing','job-2','https://x','https://x','t','o','d',null,null,null,null,'onsite','not_a_real_kind','internship',null,null,null,'h2','{}'::jsonb) $$,
+ '23514'::char(5), null, 'a malformed record (invalid opportunity_kind) is rejected'
+);
+select is((select count(*)::int from public.opportunities), 1, 'the malformed record did not create a stray opportunity, and the earlier valid one is untouched');
+
+-- Conservative closure: a partial run closes nothing; a complete run closes
+-- only after two consecutive absences.
+select public.finalize_ingestion_run(:'run_id', 'complete', 1, 1, 1, 0, '[]'::jsonb);
+
+select public.begin_ingestion_run('greenhouse:helsing', false) as partial_run_id \gset
+select public.finalize_ingestion_run(:'partial_run_id', 'partial', 0, 0, 0, 1, '[{"type":"timeout","message":"boom"}]'::jsonb);
+select is((select absence_count from public.source_listings where external_id = 'job-1'), 0::smallint, 'a partial run does not increment the absence counter');
+select is((select status from public.source_listings where external_id = 'job-1'), 'active', 'a partial run closes nothing');
+
+select public.begin_ingestion_run('greenhouse:helsing', false) as complete_run_id_1 \gset
+select public.finalize_ingestion_run(:'complete_run_id_1', 'complete', 0, 0, 0, 0, '[]'::jsonb);
+select is((select absence_count from public.source_listings where external_id = 'job-1'), 1::smallint, 'first complete-run absence increments the counter to one');
+select is((select status from public.source_listings where external_id = 'job-1'), 'active', 'a single complete-run absence does not yet close the listing');
+
+select public.begin_ingestion_run('greenhouse:helsing', false) as complete_run_id_2 \gset
+select public.finalize_ingestion_run(:'complete_run_id_2', 'complete', 0, 0, 0, 0, '[]'::jsonb);
+select is((select status from public.source_listings where external_id = 'job-1'), 'removed', 'two consecutive complete-run absences close the listing');
+select is((select status from public.opportunities where id = :'first_opportunity_id'), 'closed', 'the canonical opportunity closes once its only listing is closed');
+
+-- Reappearance resets the absence counter and reopens the listing/opportunity.
+select outcome from public.apply_source_listing(
+ 'greenhouse:helsing', 'job-1', 'https://job-boards.greenhouse.io/helsing/jobs/1', 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ 'Software Engineer Intern', 'Helsing', 'Description one, revised.', 'Munich, Germany', 'Germany', 'Bavaria', 'Munich',
+ 'onsite', 'internship', 'internship', clock_timestamp(), null, clock_timestamp(), 'hash-2', '{}'::jsonb
+);
+select is((select absence_count from public.source_listings where external_id = 'job-1'), 0::smallint, 'reappearance resets the absence counter');
+select is((select status from public.source_listings where external_id = 'job-1'), 'active', 'reappearance reopens the listing');
+select is((select status from public.opportunities where id = :'first_opportunity_id'), 'active', 'reappearance reopens the opportunity');
+
+reset role;
+select * from finish();
+rollback;
diff --git a/supabase/tests/database/080_private_and_applications.test.sql b/supabase/tests/database/080_private_and_applications.test.sql
new file mode 100644
index 0000000..8c465ee
--- /dev/null
+++ b/supabase/tests/database/080_private_and_applications.test.sql
@@ -0,0 +1,287 @@
+-- Private manual opportunities, save/hide state, application tracking, and
+-- interview-prep notes: structure, grants, RLS, and the core correctness
+-- rules from AGENTS.md / docs/adr/ADR-018. All fixture data is synthetic
+-- and this transaction is rolled back.
+begin;
+select plan(55);
+
+-- Structure -----------------------------------------------------------------
+
+select has_table('public', 'private_opportunities', 'private_opportunities table exists');
+select has_table('public', 'user_opportunity_state', 'user_opportunity_state table exists');
+select has_table('public', 'applications', 'applications table exists');
+select has_table('public', 'interview_prep_notes', 'interview_prep_notes table exists');
+select fk_ok('public', 'user_opportunity_state', 'opportunity_id', 'public', 'opportunities', 'id', 'user_opportunity_state.opportunity_id references opportunities');
+select fk_ok('public', 'applications', 'private_opportunity_id', 'public', 'private_opportunities', 'id', 'applications.private_opportunity_id references private_opportunities');
+select fk_ok('public', 'interview_prep_notes', 'application_id', 'public', 'applications', 'id', 'interview_prep_notes.application_id references applications');
+
+select policies_are(
+ 'public', 'private_opportunities',
+ array['private_opportunities_select_own', 'private_opportunities_insert_own', 'private_opportunities_update_own', 'private_opportunities_delete_own'],
+ 'private_opportunities has exactly the own-row policies'
+);
+select policies_are(
+ 'public', 'applications',
+ array['applications_select_own', 'applications_insert_own', 'applications_update_own', 'applications_delete_own'],
+ 'applications has exactly the own-row policies'
+);
+
+select is(
+ not has_column_privilege('authenticated', 'public.private_opportunities', 'promoted_to_opportunity_id', 'INSERT, UPDATE'),
+ true,
+ 'browser has no write grant on promoted_to_opportunity_id'
+);
+select is(
+ not has_column_privilege('authenticated', 'public.applications', 'opportunity_version_id', 'UPDATE')
+ and not has_column_privilege('authenticated', 'public.applications', 'private_opportunity_snapshot', 'UPDATE')
+ and not has_column_privilege('authenticated', 'public.applications', 'user_id', 'UPDATE'),
+ true,
+ 'browser cannot rewrite the pinned source, snapshot, or owner of an application'
+);
+
+select table_privs_are('public', 'private_opportunities', 'anon', array[]::text[], 'anon has no private_opportunities grants');
+select table_privs_are('public', 'applications', 'anon', array[]::text[], 'anon has no applications grants');
+
+-- Fixtures --------------------------------------------------------------
+
+insert into auth.users (id, aud, role, email) values
+ ('83000000-0000-0000-0000-000000000001', 'authenticated', 'authenticated', 'opp-a@example.test'),
+ ('83000000-0000-0000-0000-000000000002', 'authenticated', 'authenticated', 'opp-b@example.test');
+insert into public.profiles (user_id, headline) values
+ ('83000000-0000-0000-0000-000000000001', 'A'),
+ ('83000000-0000-0000-0000-000000000002', 'B');
+
+set role service_role;
+select public.begin_ingestion_run('greenhouse:helsing', false) as run_id \gset
+select opportunity_id, opportunity_version_id from public.apply_source_listing(
+ 'greenhouse:helsing', 'job-shared-1', 'https://job-boards.greenhouse.io/helsing/jobs/1', 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ 'Software Engineer Intern', 'Helsing', 'Description.', 'Munich, Germany', 'Germany', 'Bavaria', 'Munich',
+ 'onsite', 'internship', 'internship', clock_timestamp(), null, clock_timestamp(), 'hash-1', '{}'::jsonb
+) \gset shared_
+select public.finalize_ingestion_run(:'run_id', 'complete', 1, 1, 0, 0, '[]'::jsonb);
+
+-- Owner CRUD, save/hide, and manual opportunities ----------------------------
+
+reset role;
+select set_config('request.jwt.claims', json_build_object('sub', '83000000-0000-0000-0000-000000000001', 'role', 'authenticated')::text, true);
+set role authenticated;
+
+select lives_ok(
+ $$ insert into public.private_opportunities (user_id, source_url, title, organization_name, location_text, opportunity_kind, employment_type)
+ values ('83000000-0000-0000-0000-000000000001', 'https://example.test/job', 'Research Assistant', 'Fraunhofer IWES', 'Bremen, Germany', 'research_assistant', 'part_time') $$,
+ 'owner can create a manual opportunity'
+);
+select throws_ok(
+ $$ insert into public.private_opportunities (user_id, source_url, title, organization_name, location_text, opportunity_kind, employment_type)
+ values ('83000000-0000-0000-0000-000000000002', 'https://example.test/forged', 'x', 'x', 'x', 'other', 'other') $$,
+ '42501'::char(5), null, 'a forged user_id on insert is rejected'
+);
+select throws_ok(
+ $$ update public.private_opportunities set promoted_to_opportunity_id = gen_random_uuid() where organization_name = 'Fraunhofer IWES' $$,
+ '42501'::char(5), null, 'browser cannot write promoted_to_opportunity_id'
+);
+select lives_ok(
+ $$ update public.private_opportunities set dismissed_at = clock_timestamp() where organization_name = 'Fraunhofer IWES' $$,
+ 'owner can hide (dismiss) a manual opportunity'
+);
+select lives_ok(
+ $$ update public.private_opportunities set dismissed_at = null where organization_name = 'Fraunhofer IWES' $$,
+ 'owner can unhide a manual opportunity'
+);
+
+select lives_ok(
+ $$ insert into public.user_opportunity_state (user_id, opportunity_id, saved_at, saved_opportunity_version_id)
+ values ('83000000-0000-0000-0000-000000000001', $$ || quote_literal(:'shared_opportunity_id') || $$, clock_timestamp(), $$ || quote_literal(:'shared_opportunity_version_id') || $$) $$,
+ 'owner can save a shared opportunity, pinning the current version'
+);
+select is(
+ (select saved_opportunity_version_id from public.user_opportunity_state where opportunity_id = :'shared_opportunity_id'::uuid),
+ :'shared_opportunity_version_id'::uuid,
+ 'the saved state pins the exact version visible at save time'
+);
+select throws_ok(
+ format(
+ $$ insert into public.user_opportunity_state (user_id, opportunity_id, hidden_at) values ('83000000-0000-0000-0000-000000000001', %L, clock_timestamp()) $$,
+ gen_random_uuid()
+ ),
+ '23503'::char(5), null, 'hiding (or saving) requires a real opportunity_id'
+);
+select lives_ok(
+ $$ update public.user_opportunity_state set hidden_at = clock_timestamp() where opportunity_id = $$ || quote_literal(:'shared_opportunity_id') || $$::uuid $$,
+ 'owner can additionally hide a saved opportunity'
+);
+select throws_ok(
+ $$ insert into public.user_opportunity_state (user_id, opportunity_id) values ('83000000-0000-0000-0000-000000000001', gen_random_uuid()) $$,
+ '23514'::char(5), null, 'a state row with neither saved_at nor hidden_at is rejected'
+);
+
+-- Applications: exactly one source, pinning, and snapshot capture -----------
+
+select throws_ok(
+ $$ insert into public.applications (user_id) values ('83000000-0000-0000-0000-000000000001') $$,
+ '23514'::char(5), null, 'an application with neither a shared version nor a manual opportunity is rejected'
+);
+select throws_ok(
+ format(
+ $$ insert into public.applications (user_id, opportunity_version_id, private_opportunity_id) values ('83000000-0000-0000-0000-000000000001', %L, (select id from public.private_opportunities where organization_name = 'Fraunhofer IWES')) $$,
+ :'shared_opportunity_version_id'
+ ),
+ '23514'::char(5), null, 'an application referencing both a shared version and a manual opportunity is rejected'
+);
+
+select lives_ok(
+ format($$ insert into public.applications (user_id, opportunity_version_id) values ('83000000-0000-0000-0000-000000000001', %L) $$, :'shared_opportunity_version_id'),
+ 'owner can start a shared application, pinning the current version'
+);
+select is(
+ (select status from public.applications where opportunity_version_id = :'shared_opportunity_version_id'::uuid),
+ 'preparing', 'a new shared application defaults to preparing'
+);
+select is(
+ (select applied_at from public.applications where opportunity_version_id = :'shared_opportunity_version_id'::uuid),
+ null::timestamptz, 'preparing has no applied_at yet'
+);
+
+select lives_ok(
+ $$ insert into public.applications (user_id, private_opportunity_id, status)
+ values ('83000000-0000-0000-0000-000000000001', (select id from public.private_opportunities where organization_name = 'Fraunhofer IWES'), 'applied') $$,
+ 'owner can start a manual application'
+);
+select is(
+ (select private_opportunity_snapshot ->> 'organization_name' from public.applications where private_opportunity_id = (select id from public.private_opportunities where organization_name = 'Fraunhofer IWES')),
+ 'Fraunhofer IWES', 'the manual application captures a snapshot of the relevant listing fields'
+);
+select is(
+ (select applied_at is not null from public.applications where private_opportunity_id = (select id from public.private_opportunities where organization_name = 'Fraunhofer IWES')),
+ true, 'starting directly at a non-preparing status sets applied_at automatically'
+);
+
+select throws_ok(
+ format(
+ $$ insert into public.applications (user_id, private_opportunity_id) values ('83000000-0000-0000-0000-000000000001', %L) $$,
+ gen_random_uuid()
+ ),
+ 'P0002'::char(5), null, 'referencing a nonexistent private opportunity fails (caught by the trigger before the FK constraint)'
+);
+
+-- Editing the manual opportunity afterward must not alter the captured snapshot.
+update public.private_opportunities set title = 'Research Assistant (Renamed)' where organization_name = 'Fraunhofer IWES';
+select is(
+ (select private_opportunity_snapshot ->> 'title' from public.applications where private_opportunity_id = (select id from public.private_opportunities where organization_name = 'Fraunhofer IWES')),
+ 'Research Assistant', 'the application snapshot is immutable even after the private opportunity is edited'
+);
+
+select throws_ok(
+ format($$ update public.applications set opportunity_version_id = %L where opportunity_version_id = %L $$, :'shared_opportunity_version_id', :'shared_opportunity_version_id'),
+ '42501'::char(5), null, 'the browser cannot rewrite the pinned opportunity_version_id'
+);
+
+-- Status/applied_at/status_updated_at behavior -------------------------------
+
+select status_updated_at as t0 from public.applications where opportunity_version_id = :'shared_opportunity_version_id'::uuid \gset shared_app_
+select lives_ok(
+ format($$ update public.applications set notes = 'talked to recruiter' where opportunity_version_id = %L $$, :'shared_opportunity_version_id'),
+ 'owner can add notes'
+);
+select is(
+ (select status_updated_at from public.applications where opportunity_version_id = :'shared_opportunity_version_id'::uuid),
+ :'shared_app_t0'::timestamptz, 'an ordinary note edit does not advance status_updated_at'
+);
+select lives_ok(
+ format($$ update public.applications set status = 'applied' where opportunity_version_id = %L $$, :'shared_opportunity_version_id'),
+ 'owner can change status'
+);
+select isnt(
+ (select status_updated_at from public.applications where opportunity_version_id = :'shared_opportunity_version_id'::uuid),
+ :'shared_app_t0'::timestamptz, 'a real status change advances status_updated_at'
+);
+select is(
+ (select applied_at is not null from public.applications where opportunity_version_id = :'shared_opportunity_version_id'::uuid),
+ true, 'transitioning out of preparing sets applied_at'
+);
+select throws_ok(
+ format($$ update public.applications set status = 'not_a_real_status' where opportunity_version_id = %L $$, :'shared_opportunity_version_id'),
+ '23514'::char(5), null, 'an invalid status is rejected'
+);
+
+-- Interview prep notes --------------------------------------------------
+
+select lives_ok(
+ format(
+ $$ insert into public.interview_prep_notes (application_id, likely_questions) values ((select id from public.applications where opportunity_version_id = %L), 'Tell me about a project you shipped.') $$,
+ :'shared_opportunity_version_id'
+ ),
+ 'owner can add interview prep notes'
+);
+select is(
+ (select user_id from public.interview_prep_notes where application_id = (select id from public.applications where opportunity_version_id = :'shared_opportunity_version_id'::uuid)),
+ '83000000-0000-0000-0000-000000000001'::uuid, 'interview_prep_notes.user_id is derived from the application owner, not client-supplied'
+);
+select lives_ok(
+ format(
+ $$ update public.interview_prep_notes set reflections = 'went well' where application_id = (select id from public.applications where opportunity_version_id = %L) $$,
+ :'shared_opportunity_version_id'
+ ),
+ 'owner can update interview prep notes'
+);
+
+-- Cross-user isolation --------------------------------------------------
+
+reset role;
+select set_config('request.jwt.claims', json_build_object('sub', '83000000-0000-0000-0000-000000000002', 'role', 'authenticated')::text, true);
+set role authenticated;
+
+select is_empty(
+ $$ select 1 from public.private_opportunities where user_id = '83000000-0000-0000-0000-000000000001' $$,
+ 'a second user cannot see the first user''s manual opportunities'
+);
+select is_empty(
+ $$ select 1 from public.applications where user_id = '83000000-0000-0000-0000-000000000001' $$,
+ 'a second user cannot see the first user''s applications'
+);
+select is_empty(
+ $$ select 1 from public.interview_prep_notes where user_id = '83000000-0000-0000-0000-000000000001' $$,
+ 'a second user cannot see the first user''s interview prep notes'
+);
+with attempted as (
+ update public.applications set status = 'rejected'
+ where user_id = '83000000-0000-0000-0000-000000000001'
+ returning 1
+)
+select is((select count(*)::int from attempted), 0, 'a second user cannot update the first user''s applications');
+select throws_ok(
+ format(
+ $$ insert into public.interview_prep_notes (application_id) values ((select id from public.applications where opportunity_version_id = %L)) $$,
+ :'shared_opportunity_version_id'
+ ),
+ 'P0002'::char(5), null, 'a second user cannot attach interview prep notes to the first user''s application (RLS hides it entirely, so it looks unknown rather than forbidden)'
+);
+select throws_ok(
+ $$ insert into public.applications (user_id, private_opportunity_id) values ('83000000-0000-0000-0000-000000000002', (select id from public.private_opportunities where organization_name = 'Research Assistant (Renamed)')) $$,
+ '23514'::char(5), null, 'a second user cannot start an application against the first user''s manual opportunity (RLS hides it, so the id resolves to null and fails the source-check instead)'
+);
+
+-- Deleting a referenced manual opportunity preserves the application snapshot.
+reset role;
+select set_config('request.jwt.claims', json_build_object('sub', '83000000-0000-0000-0000-000000000001', 'role', 'authenticated')::text, true);
+set role authenticated;
+delete from public.private_opportunities where organization_name = 'Fraunhofer IWES';
+select is(
+ (select private_opportunity_id from public.applications where private_opportunity_snapshot ->> 'organization_name' = 'Fraunhofer IWES'),
+ null::uuid, 'the application''s private_opportunity_id becomes null once the source record is deleted'
+);
+select is(
+ (select private_opportunity_snapshot ->> 'organization_name' from public.applications where private_opportunity_snapshot ->> 'organization_name' = 'Fraunhofer IWES'),
+ 'Fraunhofer IWES', 'the application''s snapshot survives deletion of the source manual opportunity'
+);
+
+reset role;
+set role anon;
+select throws_ok($$ select 1 from public.private_opportunities $$, '42501'::char(5), null, 'anonymous private_opportunities SELECT is denied at grants');
+select throws_ok($$ select 1 from public.applications $$, '42501'::char(5), null, 'anonymous applications SELECT is denied at grants');
+select throws_ok($$ select 1 from public.interview_prep_notes $$, '42501'::char(5), null, 'anonymous interview_prep_notes SELECT is denied at grants');
+select throws_ok($$ select 1 from public.user_opportunity_state $$, '42501'::char(5), null, 'anonymous user_opportunity_state SELECT is denied at grants');
+
+reset role;
+select * from finish();
+rollback;
From 85d31a6450f1a86f55339ddbf6ba9a02301ceada Mon Sep 17 00:00:00 2001
From: Abdulrahman
Date: Tue, 4 Aug 2026 16:51:44 +0300
Subject: [PATCH 2/8] feat: add opportunity browsing and manual entry
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).
---
app/src/App.tsx | 47 +-
app/src/lib/applicationRepository.ts | 90 ++++
app/src/lib/opportunityRepository.ts | 213 ++++++++
app/src/lib/opportunityTypes.ts | 186 +++++++
app/src/lib/privateOpportunityRepository.ts | 84 ++++
app/src/pages/AppLayout.tsx | 36 ++
app/src/pages/ManualOpportunityFormPage.tsx | 232 +++++++++
app/src/pages/OpportunityDetailPage.tsx | 256 ++++++++++
app/src/pages/OpportunityListPage.tsx | 476 ++++++++++++++++++
.../pages/PrivateOpportunityDetailPage.tsx | 425 ++++++++++++++++
app/src/pages/ProfileLayout.tsx | 26 +-
11 files changed, 2035 insertions(+), 36 deletions(-)
create mode 100644 app/src/lib/applicationRepository.ts
create mode 100644 app/src/lib/opportunityRepository.ts
create mode 100644 app/src/lib/opportunityTypes.ts
create mode 100644 app/src/lib/privateOpportunityRepository.ts
create mode 100644 app/src/pages/AppLayout.tsx
create mode 100644 app/src/pages/ManualOpportunityFormPage.tsx
create mode 100644 app/src/pages/OpportunityDetailPage.tsx
create mode 100644 app/src/pages/OpportunityListPage.tsx
create mode 100644 app/src/pages/PrivateOpportunityDetailPage.tsx
diff --git a/app/src/App.tsx b/app/src/App.tsx
index f203aca..6b7f630 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -6,6 +6,7 @@ import {
} from 'react-router'
import { RouterProvider } from 'react-router/dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
+import { AppLayout } from './pages/AppLayout'
import { ProfileLayout } from './pages/ProfileLayout'
import { BasicProfilePage } from './pages/BasicProfilePage'
import { EducationEditorPage } from './pages/EducationEditorPage'
@@ -14,6 +15,10 @@ import { ExperienceEditorPage } from './pages/ExperienceEditorPage'
import { ExperienceListPage } from './pages/ExperienceListPage'
import { ProfilePage } from './pages/ProfilePage'
import { SignInPage } from './pages/SignInPage'
+import { OpportunityListPage } from './pages/OpportunityListPage'
+import { OpportunityDetailPage } from './pages/OpportunityDetailPage'
+import { PrivateOpportunityDetailPage } from './pages/PrivateOpportunityDetailPage'
+import { ManualOpportunityFormPage } from './pages/ManualOpportunityFormPage'
function AuthPending() {
const { initError, loading, retryInit } = useAuth()
@@ -88,22 +93,40 @@ const router = createBrowserRouter([
errorElement: ,
children: [
{
- path: '/profile',
- element: ,
+ element: ,
children: [
- { index: true, element: },
- { path: 'basic', element: },
- { path: 'education', element: },
- { path: 'education/new', element: },
{
- path: 'education/:educationId/edit',
- element: ,
+ path: '/profile',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'basic', element: },
+ { path: 'education', element: },
+ { path: 'education/new', element: },
+ {
+ path: 'education/:educationId/edit',
+ element: ,
+ },
+ { path: 'experience', element: },
+ { path: 'experience/new', element: },
+ {
+ path: 'experience/:experienceId/edit',
+ element: ,
+ },
+ ],
},
- { path: 'experience', element: },
- { path: 'experience/new', element: },
+ { path: '/opportunities', element: },
{
- path: 'experience/:experienceId/edit',
- element: ,
+ path: '/opportunities/import',
+ element: ,
+ },
+ {
+ path: '/opportunities/manual/:privateOpportunityId',
+ element: ,
+ },
+ {
+ path: '/opportunities/:opportunityId',
+ element: ,
},
],
},
diff --git a/app/src/lib/applicationRepository.ts b/app/src/lib/applicationRepository.ts
new file mode 100644
index 0000000..ef10973
--- /dev/null
+++ b/app/src/lib/applicationRepository.ts
@@ -0,0 +1,90 @@
+import { supabase } from './supabaseClient'
+import type { Application, ApplicationStatus } from './opportunityTypes'
+
+const columns =
+ 'id, user_id, opportunity_version_id, private_opportunity_id, private_opportunity_snapshot, status, applied_at, status_updated_at, next_action, next_action_due_at, notes, contact_note, created_at, updated_at'
+
+export interface ApplicationFieldsInput {
+ next_action?: string | null
+ next_action_due_at?: string | null
+ notes?: string | null
+ contact_note?: string | null
+}
+
+export const applicationRepository = {
+ async list(userId: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('user_id', userId)
+ .order('status_updated_at', { ascending: false })
+ .returns()
+ },
+
+ async get(id: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('id', id)
+ .maybeSingle()
+ },
+
+ async getByOpportunityVersion(userId: string, opportunityVersionId: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('user_id', userId)
+ .eq('opportunity_version_id', opportunityVersionId)
+ .maybeSingle()
+ },
+
+ async getByPrivateOpportunity(userId: string, privateOpportunityId: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('user_id', userId)
+ .eq('private_opportunity_id', privateOpportunityId)
+ .maybeSingle()
+ },
+
+ async createForOpportunity(userId: string, opportunityVersionId: string) {
+ return supabase
+ .from('applications')
+ .insert({ user_id: userId, opportunity_version_id: opportunityVersionId })
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async createForPrivateOpportunity(
+ userId: string,
+ privateOpportunityId: string,
+ ) {
+ return supabase
+ .from('applications')
+ .insert({ user_id: userId, private_opportunity_id: privateOpportunityId })
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async updateStatus(id: string, status: ApplicationStatus) {
+ return supabase
+ .from('applications')
+ .update({ status })
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async updateFields(id: string, input: ApplicationFieldsInput) {
+ return supabase
+ .from('applications')
+ .update(input)
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async remove(id: string) {
+ return supabase.from('applications').delete().eq('id', id).select('id')
+ },
+}
diff --git a/app/src/lib/opportunityRepository.ts b/app/src/lib/opportunityRepository.ts
new file mode 100644
index 0000000..2c87d0d
--- /dev/null
+++ b/app/src/lib/opportunityRepository.ts
@@ -0,0 +1,213 @@
+import { supabase } from './supabaseClient'
+import type {
+ OpportunitySearchRow,
+ UserOpportunityStateRow,
+} from './opportunityTypes'
+
+export type OpportunitySort =
+ | 'discovered_desc'
+ | 'posted_desc'
+ | 'deadline_asc'
+ | 'organization_asc'
+ | 'title_asc'
+
+export interface OpportunityFilters {
+ search?: string
+ opportunityKind?: string
+ employmentType?: string
+ remoteMode?: string
+ sourceKey?: string
+ lifecycleStatus?: string
+}
+
+const searchColumns =
+ 'opportunity_id, opportunity_version_id, lifecycle_status, first_discovered_at, last_checked_at, source_id, source_key, source_display_name, source_listing_id, canonical_source_url, title, organization, description, location_text, country, region, city, opportunity_kind, employment_type, remote_mode, posted_at, application_deadline, application_url, version_captured_at'
+
+const stateColumns =
+ 'id, user_id, opportunity_id, saved_at, saved_opportunity_version_id, hidden_at, notes'
+
+function escapeForIlike(term: string) {
+ return term.replace(/[%_,]/g, (char) => `\\${char}`)
+}
+
+export const PAGE_SIZE = 20
+
+export const opportunityRepository = {
+ async search(
+ filters: OpportunityFilters,
+ sort: OpportunitySort,
+ page: number,
+ pageSize = PAGE_SIZE,
+ ) {
+ let query = supabase
+ .from('opportunity_search')
+ .select(searchColumns, { count: 'exact' })
+
+ if (filters.search?.trim()) {
+ const term = escapeForIlike(filters.search.trim())
+ query = query.or(
+ `title.ilike.%${term}%,organization.ilike.%${term}%,description.ilike.%${term}%,location_text.ilike.%${term}%`,
+ )
+ }
+ if (filters.opportunityKind)
+ query = query.eq('opportunity_kind', filters.opportunityKind)
+ if (filters.employmentType)
+ query = query.eq('employment_type', filters.employmentType)
+ if (filters.remoteMode) query = query.eq('remote_mode', filters.remoteMode)
+ if (filters.sourceKey) query = query.eq('source_key', filters.sourceKey)
+ if (filters.lifecycleStatus)
+ query = query.eq('lifecycle_status', filters.lifecycleStatus)
+
+ switch (sort) {
+ case 'posted_desc':
+ query = query.order('posted_at', {
+ ascending: false,
+ nullsFirst: false,
+ })
+ break
+ case 'deadline_asc':
+ query = query.order('application_deadline', {
+ ascending: true,
+ nullsFirst: false,
+ })
+ break
+ case 'organization_asc':
+ query = query.order('organization', { ascending: true })
+ break
+ case 'title_asc':
+ query = query.order('title', { ascending: true })
+ break
+ case 'discovered_desc':
+ default:
+ query = query.order('first_discovered_at', { ascending: false })
+ break
+ }
+
+ const from = page * pageSize
+ query = query.range(from, from + pageSize - 1)
+ return query.returns()
+ },
+
+ async get(opportunityId: string) {
+ return supabase
+ .from('opportunity_search')
+ .select(searchColumns)
+ .eq('opportunity_id', opportunityId)
+ .maybeSingle()
+ },
+
+ async getVersion(opportunityVersionId: string) {
+ return supabase
+ .from('opportunity_versions')
+ .select(
+ 'id, opportunity_id, title, organization, description, location_text, remote_mode, opportunity_kind, employment_type, application_url, posted_at, application_deadline, captured_at',
+ )
+ .eq('id', opportunityVersionId)
+ .maybeSingle()
+ },
+
+ async getVersions(opportunityVersionIds: string[]) {
+ if (opportunityVersionIds.length === 0) return { data: [], error: null }
+ return supabase
+ .from('opportunity_versions')
+ .select(
+ 'id, opportunity_id, title, organization, application_url, application_deadline',
+ )
+ .in('id', opportunityVersionIds)
+ },
+
+ async listState(userId: string) {
+ return supabase
+ .from('user_opportunity_state')
+ .select(stateColumns)
+ .eq('user_id', userId)
+ .returns()
+ },
+
+ async getState(userId: string, opportunityId: string) {
+ return supabase
+ .from('user_opportunity_state')
+ .select(stateColumns)
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .maybeSingle()
+ },
+
+ async setSaved(
+ userId: string,
+ opportunityId: string,
+ opportunityVersionId: string,
+ saved: boolean,
+ ) {
+ const existing = await this.getState(userId, opportunityId)
+ if (existing.error) return existing
+ if (saved) {
+ return supabase
+ .from('user_opportunity_state')
+ .upsert(
+ {
+ user_id: userId,
+ opportunity_id: opportunityId,
+ saved_at: new Date().toISOString(),
+ saved_opportunity_version_id: opportunityVersionId,
+ },
+ { onConflict: 'user_id,opportunity_id' },
+ )
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data?.hidden_at) {
+ return supabase
+ .from('user_opportunity_state')
+ .update({ saved_at: null, saved_opportunity_version_id: null })
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data) {
+ return supabase
+ .from('user_opportunity_state')
+ .delete()
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ }
+ return { data: null, error: null }
+ },
+
+ async setHidden(userId: string, opportunityId: string, hidden: boolean) {
+ const existing = await this.getState(userId, opportunityId)
+ if (existing.error) return existing
+ if (hidden) {
+ return supabase
+ .from('user_opportunity_state')
+ .upsert(
+ {
+ user_id: userId,
+ opportunity_id: opportunityId,
+ hidden_at: new Date().toISOString(),
+ },
+ { onConflict: 'user_id,opportunity_id' },
+ )
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data?.saved_at) {
+ return supabase
+ .from('user_opportunity_state')
+ .update({ hidden_at: null })
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data) {
+ return supabase
+ .from('user_opportunity_state')
+ .delete()
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ }
+ return { data: null, error: null }
+ },
+}
diff --git a/app/src/lib/opportunityTypes.ts b/app/src/lib/opportunityTypes.ts
new file mode 100644
index 0000000..3f0e40d
--- /dev/null
+++ b/app/src/lib/opportunityTypes.ts
@@ -0,0 +1,186 @@
+export type OpportunityKind =
+ | 'internship'
+ | 'working_student'
+ | 'graduate_program'
+ | 'entry_level'
+ | 'research_assistant'
+ | 'phd'
+ | 'scholarship'
+ | 'hackathon'
+ | 'fellowship'
+ | 'other'
+
+export type EmploymentType =
+ | 'full_time'
+ | 'part_time'
+ | 'contract'
+ | 'temporary'
+ | 'internship'
+ | 'volunteer'
+ | 'other'
+
+export type RemoteMode = 'onsite' | 'hybrid' | 'remote' | 'unknown'
+
+export type LifecycleStatus = 'active' | 'stale' | 'closed' | 'unknown'
+
+export type ApplicationStatus =
+ | 'preparing'
+ | 'applied'
+ | 'awaiting_response'
+ | 'interview_scheduled'
+ | 'interview_complete'
+ | 'offer'
+ | 'accepted'
+ | 'rejected'
+ | 'withdrawn'
+ | 'closed'
+
+export interface OpportunitySearchRow {
+ opportunity_id: string
+ opportunity_version_id: string
+ lifecycle_status: LifecycleStatus
+ first_discovered_at: string
+ last_checked_at: string
+ source_id: string
+ source_key: string
+ source_display_name: string
+ source_listing_id: string
+ canonical_source_url: string
+ title: string
+ organization: string
+ description: string
+ location_text: string | null
+ country: string | null
+ region: string | null
+ city: string | null
+ opportunity_kind: OpportunityKind
+ employment_type: EmploymentType
+ remote_mode: RemoteMode
+ posted_at: string | null
+ application_deadline: string | null
+ application_url: string | null
+ version_captured_at: string
+}
+
+export interface UserOpportunityStateRow {
+ id: string
+ user_id: string
+ opportunity_id: string
+ saved_at: string | null
+ saved_opportunity_version_id: string | null
+ hidden_at: string | null
+ notes: string | null
+}
+
+export interface PrivateOpportunity {
+ id: string
+ user_id: string
+ source_url: string
+ title: string
+ organization_name: string
+ location_text: string
+ opportunity_kind: OpportunityKind
+ employment_type: EmploymentType
+ remote_mode: RemoteMode
+ description_text: string | null
+ posted_at: string | null
+ application_deadline: string | null
+ application_url: string | null
+ dismissed_at: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface Application {
+ id: string
+ user_id: string
+ opportunity_version_id: string | null
+ private_opportunity_id: string | null
+ private_opportunity_snapshot: Record | null
+ status: ApplicationStatus
+ applied_at: string | null
+ status_updated_at: string
+ next_action: string | null
+ next_action_due_at: string | null
+ notes: string | null
+ contact_note: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface InterviewPrepNotes {
+ id: string
+ application_id: string
+ responsibilities_to_discuss: string | null
+ required_technologies: string | null
+ topics_to_revise: string | null
+ likely_questions: string | null
+ questions_to_ask: string | null
+ interview_date: string | null
+ interview_format: string | null
+ reflections: string | null
+}
+
+export const opportunityKindLabels: Record = {
+ internship: 'Internship',
+ working_student: 'Working student',
+ graduate_program: 'Graduate program',
+ entry_level: 'Entry level',
+ research_assistant: 'Research assistant',
+ phd: 'PhD',
+ scholarship: 'Scholarship',
+ hackathon: 'Hackathon',
+ fellowship: 'Fellowship',
+ other: 'Other',
+}
+
+export const employmentTypeLabels: Record = {
+ full_time: 'Full-time',
+ part_time: 'Part-time',
+ contract: 'Contract',
+ temporary: 'Temporary',
+ internship: 'Internship',
+ volunteer: 'Volunteer',
+ other: 'Other',
+}
+
+export const remoteModeLabels: Record = {
+ onsite: 'On-site',
+ hybrid: 'Hybrid',
+ remote: 'Remote',
+ unknown: 'Unknown',
+}
+
+export const lifecycleStatusLabels: Record = {
+ active: 'Active',
+ stale: 'Stale',
+ closed: 'Closed',
+ unknown: 'Unknown',
+}
+
+export const applicationStatusLabels: Record = {
+ preparing: 'Preparing',
+ applied: 'Applied',
+ awaiting_response: 'Awaiting response',
+ interview_scheduled: 'Interview scheduled',
+ interview_complete: 'Interview complete',
+ offer: 'Offer',
+ accepted: 'Accepted',
+ rejected: 'Rejected',
+ withdrawn: 'Withdrawn',
+ closed: 'Closed',
+}
+
+export const applicationStatusOptions = Object.keys(
+ applicationStatusLabels,
+) as ApplicationStatus[]
+
+export const opportunityKindOptions = Object.keys(
+ opportunityKindLabels,
+) as OpportunityKind[]
+
+export const employmentTypeOptions = Object.keys(
+ employmentTypeLabels,
+) as EmploymentType[]
+
+export const remoteModeOptions = Object.keys(remoteModeLabels) as RemoteMode[]
diff --git a/app/src/lib/privateOpportunityRepository.ts b/app/src/lib/privateOpportunityRepository.ts
new file mode 100644
index 0000000..31f6c3c
--- /dev/null
+++ b/app/src/lib/privateOpportunityRepository.ts
@@ -0,0 +1,84 @@
+import { supabase } from './supabaseClient'
+import type {
+ EmploymentType,
+ OpportunityKind,
+ PrivateOpportunity,
+ RemoteMode,
+} from './opportunityTypes'
+
+export type PrivateOpportunityInput = {
+ source_url: string
+ title: string
+ organization_name: string
+ location_text: string
+ opportunity_kind: OpportunityKind
+ employment_type: EmploymentType
+ remote_mode: RemoteMode
+ description_text: string | null
+ posted_at: string | null
+ application_deadline: string | null
+ application_url: string | null
+}
+
+export type PrivateOpportunityUpdate = Omit<
+ PrivateOpportunityInput,
+ 'source_url'
+>
+
+const columns =
+ 'id, user_id, source_url, title, organization_name, location_text, opportunity_kind, employment_type, remote_mode, description_text, posted_at, application_deadline, application_url, dismissed_at, created_at, updated_at'
+
+export const privateOpportunityRepository = {
+ async list(userId: string, includeDismissed: boolean) {
+ let query = supabase
+ .from('private_opportunities')
+ .select(columns)
+ .eq('user_id', userId)
+ if (!includeDismissed) query = query.is('dismissed_at', null)
+ return query
+ .order('created_at', { ascending: false })
+ .returns()
+ },
+
+ async get(id: string) {
+ return supabase
+ .from('private_opportunities')
+ .select(columns)
+ .eq('id', id)
+ .maybeSingle()
+ },
+
+ async create(userId: string, input: PrivateOpportunityInput) {
+ return supabase
+ .from('private_opportunities')
+ .insert({ user_id: userId, ...input })
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async update(id: string, input: PrivateOpportunityUpdate) {
+ return supabase
+ .from('private_opportunities')
+ .update(input)
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async setDismissed(id: string, dismissed: boolean) {
+ return supabase
+ .from('private_opportunities')
+ .update({ dismissed_at: dismissed ? new Date().toISOString() : null })
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async remove(id: string) {
+ return supabase
+ .from('private_opportunities')
+ .delete()
+ .eq('id', id)
+ .select('id')
+ },
+}
diff --git a/app/src/pages/AppLayout.tsx b/app/src/pages/AppLayout.tsx
new file mode 100644
index 0000000..2f78c5b
--- /dev/null
+++ b/app/src/pages/AppLayout.tsx
@@ -0,0 +1,36 @@
+import { NavLink, Outlet } from 'react-router'
+import { useState } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+
+export function AppLayout() {
+ const { signOut } = useAuth()
+ const [error, setError] = useState(null)
+
+ async function handleSignOut() {
+ setError(null)
+ const result = await signOut()
+ if (result.error) setError(result.error)
+ }
+
+ return (
+
+
+
CareerOS
+
+
+ {error &&
{error}
}
+
+
+
+ )
+}
diff --git a/app/src/pages/ManualOpportunityFormPage.tsx b/app/src/pages/ManualOpportunityFormPage.tsx
new file mode 100644
index 0000000..70ee689
--- /dev/null
+++ b/app/src/pages/ManualOpportunityFormPage.tsx
@@ -0,0 +1,232 @@
+import { Link, useNavigate } from 'react-router'
+import { useState, type FormEvent } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { privateOpportunityRepository } from '../lib/privateOpportunityRepository'
+import {
+ employmentTypeLabels,
+ employmentTypeOptions,
+ opportunityKindLabels,
+ opportunityKindOptions,
+ remoteModeLabels,
+ remoteModeOptions,
+ type EmploymentType,
+ type OpportunityKind,
+ type RemoteMode,
+} from '../lib/opportunityTypes'
+import { errorMessage, safeError } from '../lib/profileTypes'
+
+const empty = {
+ source_url: '',
+ title: '',
+ organization_name: '',
+ location_text: '',
+ opportunity_kind: 'other' as OpportunityKind,
+ employment_type: 'other' as EmploymentType,
+ remote_mode: 'unknown' as RemoteMode,
+ description_text: '',
+ posted_at: '',
+ application_deadline: '',
+ application_url: '',
+}
+
+function validate(values: typeof empty) {
+ const errors: Record = {}
+ if (!values.source_url.trim().startsWith('https://'))
+ errors.source_url = 'Enter the https:// URL where you found this listing.'
+ if (!values.title.trim()) errors.title = 'Title is required.'
+ if (!values.organization_name.trim())
+ errors.organization_name = 'Organization is required.'
+ if (!values.location_text.trim())
+ errors.location_text = 'Location is required.'
+ if (values.application_url && !values.application_url.startsWith('https://'))
+ errors.application_url = 'Application URL must start with https://.'
+ return errors
+}
+
+export function ManualOpportunityFormPage() {
+ const { session } = useAuth()
+ const navigate = useNavigate()
+ const [values, setValues] = useState(empty)
+ const [errors, setErrors] = useState>({})
+ const [message, setMessage] = useState(null)
+ const [saving, setSaving] = useState(false)
+
+ function field(name: K, value: string) {
+ setValues((current) => ({ ...current, [name]: value }))
+ }
+
+ async function save(event: FormEvent) {
+ event.preventDefault()
+ const nextErrors = validate(values)
+ setErrors(nextErrors)
+ if (Object.keys(nextErrors).length || saving || !session) return
+ setSaving(true)
+ const result = await privateOpportunityRepository.create(session.user.id, {
+ source_url: values.source_url.trim(),
+ title: values.title.trim(),
+ organization_name: values.organization_name.trim(),
+ location_text: values.location_text.trim(),
+ opportunity_kind: values.opportunity_kind,
+ employment_type: values.employment_type,
+ remote_mode: values.remote_mode,
+ description_text: values.description_text.trim() || null,
+ posted_at: values.posted_at || null,
+ application_deadline: values.application_deadline || null,
+ application_url: values.application_url.trim() || null,
+ })
+ if (result.error || !result.data) {
+ setMessage(errorMessage(safeError(result.error)))
+ setSaving(false)
+ return
+ }
+ navigate(`/opportunities/manual/${result.data.id}`, { replace: true })
+ }
+
+ return (
+
+
Add an opportunity manually
+
+ Use this for a posting from a source CareerOS doesn't automatically
+ import. You confirm the details yourself — nothing is fetched
+ automatically.
+