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. +

+
+
+ + field('source_url', event.target.value)} + aria-invalid={Boolean(errors.source_url)} + /> + {errors.source_url &&

{errors.source_url}

} +
+
+ + field('title', event.target.value)} + aria-invalid={Boolean(errors.title)} + /> + {errors.title &&

{errors.title}

} +
+
+ + field('organization_name', event.target.value)} + aria-invalid={Boolean(errors.organization_name)} + /> + {errors.organization_name && ( +

{errors.organization_name}

+ )} +
+
+ + field('location_text', event.target.value)} + aria-invalid={Boolean(errors.location_text)} + /> + {errors.location_text &&

{errors.location_text}

} +
+
+ + +
+
+ + +
+
+ + +
+
+ +