From dc7063e5aba90354e168945a20ba5b9cdac59a44 Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Mon, 3 Aug 2026 11:05:23 +0300 Subject: [PATCH 1/3] feat: add work experience schema and freshness Add the manual work-experience table with honest month/year date precision, explicit grants and own-row RLS, and database-maintained experience review freshness. Cover current-schema migration compatibility and pgTAP structure, validation, ownership, and function-hardening behavior. --- .github/workflows/ci.yml | 3 + README.md | 9 +- docs/DATA_MODEL.md | 10 +- docs/RLS_POLICY_MATRIX.md | 11 +- docs/SECURITY_AND_PRIVACY.md | 15 +- .../assert_work_experience_migration.sql | 54 ++++ .../migration/current_profile_fixture.sql | 32 ++ .../20260803090000_work_experience.sql | 297 ++++++++++++++++++ ...experience-migration-compatibility-test.sh | 10 + .../tests/database/000_structure.test.sql | 7 +- .../040_privileged_function_security.test.sql | 12 +- .../database/060_work_experience_rls.test.sql | 292 +++++++++++++++++ 12 files changed, 730 insertions(+), 22 deletions(-) create mode 100644 supabase/fixtures/migration/assert_work_experience_migration.sql create mode 100644 supabase/fixtures/migration/current_profile_fixture.sql create mode 100644 supabase/migrations/20260803090000_work_experience.sql create mode 100755 supabase/scripts/work-experience-migration-compatibility-test.sh create mode 100644 supabase/tests/database/060_work_experience_rls.test.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0ff2e..5c684cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,9 @@ jobs: - name: Verify Phase 0-to-Phase 1A migration compatibility run: ./supabase/scripts/migration-compatibility-test.sh + - name: Verify profile-core-to-experience migration compatibility + run: ./supabase/scripts/work-experience-migration-compatibility-test.sh + - name: Reset database from empty for test suite run: supabase db reset diff --git a/README.md b/README.md index 1119734..fbe5ff2 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ hackathons, scholarships, and other resume-building opportunities; compares them detailed saved profile; explains where the student is competitive and where they are not; and tracks the full lifecycle from "found it" to "applied" to "interviewed." -Phase 0 is implemented, and the first Phase 1A profile-core/education slice adds routed manual -profile and primary-education workflows: a React/TypeScript/Vite frontend, a local Supabase stack -(Postgres/Auth/PostgREST), the initial `profiles`/`education_entries` schema with Row Level -Security, and CI. See [Local development setup](#local-development-setup) below to run it. +Phase 0 is implemented. Phase 1A currently provides routed manual profile, primary-education, +and experience/research workflows: a React/TypeScript/Vite frontend, a local Supabase stack +(Postgres/Auth/PostgREST), `profiles`, `education_entries`, and `work_experience` protected by +Row Level Security, and CI. Projects, links, skills, preferences, and resume features remain +deferred. See [Local development setup](#local-development-setup) below to run it. This repository is public for portfolio, education, and review purposes — see [License status](#license-status) and [Contributing](#contributing) below before assuming more diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 5d24847..5b536d3 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -81,10 +81,10 @@ erDiagram ## Profile domain (Phase 1A proposal; user-owned unless marked shared) -**Implementation status**: the first Phase 1A vertical slice is implemented: refined -`profiles`, refined `education_entries`, and section freshness/review rows for `basic_profile` -and `education`. The remaining profile-domain tables below remain logical targets, not -implemented SQL. Phase 1B resume and suggestion records are expressly excluded. +**Implementation status**: the implemented Phase 1A slices include refined `profiles`, refined +`education_entries`, `work_experience`, and section freshness/review rows for `basic_profile`, +`education`, and `experience`. The remaining profile-domain tables below remain logical targets, +not implemented SQL. Phase 1B resume and suggestion records are expressly excluded. ### Implemented profile-core migration note @@ -116,7 +116,7 @@ row context, not a Phase 1B provenance solution. | `education_entries` | Education history and authoritative education facts; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; institution, degree, field, `degree_year`, expected graduation month/year, start date, and `education_status`; date/order checks. `is_primary` is optional, but partial unique `(user_id) WHERE is_primary` permits at most one primary entry. A primary normally must be current; explicit status constraint enforces that relationship. Multiple active entries remain allowed. | Cascade from profile; own-row RLS; High; browser CRUD; completeness, matching, evidence. A later forward migration copies Phase 0 `profiles.degree_program`/`degree_year` into the selected or created primary entry before removing their authoritative role. | | `profile_section_reviews` | Deliberate review of a UI section, including intentionally empty sections; user-owned; composite PK `(user_id, section_key)`. | `(user_id)` → profiles; controlled keys: `basic_profile`, `education`, `experience`, `projects`, `skills`, `languages`, `preferences`, `work_eligibility`; `reviewed_at`, `reviewed_content_revision`, timestamps. | Cascade from profile; own-row RLS; High; browser CRUD; deterministic review state/completeness. | | `profile_section_revisions` | Minimal deletion-safe content freshness state; user-owned metadata; composite PK `(user_id, section_key)`. | Same controlled keys; non-negative monotonic `content_revision`, `updated_at`. Created at revision zero for each section; database-maintained mutation path increments it transactionally on relevant row create/update/delete. | Cascade from profile; own-row RLS; Medium; browser SELECT only; supports review freshness without a reset-flag trigger. | -| `work_experience` | Employment, research, or volunteering evidence; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; role/organization required; date order; bounded description. | Cascade from profile; own-row RLS; High; browser CRUD; evidence and future matching. | +| `work_experience` | Employment, internship, research, volunteering, student-leadership, or other evidence; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; `UNIQUE (user_id, id)` reserves an ownership-safe future evidence target. Required kind, organization, role, and start year; optional month precision and bounded plain-text location/description. End periods cannot precede start periods; a current row has no end period. | Cascade from profile; own-row RLS; High; browser CRUD; experience review/completeness and future matching. Deleting a future evidence target must cascade only its evidence rows, not the user’s skill claim. | | `projects` | User project evidence; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; title required; bounded description; optional safe URL. | Cascade from profile; own-row RLS; High; browser CRUD; completeness, evidence, matching. | | `profile_links` | Links to portfolio/GitHub/public work; user-owned; `id` PK, direct `user_id`. | `(user_id)` → `profiles`; URL scheme/length allowlist; labeled link. | Cascade from profile; own-row RLS; Medium; browser CRUD; evidence and optional enrichment. | | `skills_catalog` | Small canonical skill names for matching; shared; `id` PK. | Normalized canonical name unique; bounded category and controlled `is_technical` classification; no user data. | Service maintains; authenticated read only; Low; browser SELECT only; matching and market aggregation. | diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md index 471dfb6..b567f5a 100644 --- a/docs/RLS_POLICY_MATRIX.md +++ b/docs/RLS_POLICY_MATRIX.md @@ -57,10 +57,10 @@ both this file and its corresponding test. ## Profile domain (user-owned) -**Status**: `profiles`, `education_entries`, `profile_section_revisions`, and -`profile_section_reviews` are implemented and pgTAP-tested in the first Phase 1A slice. All other -rows in this profile-domain section remain proposed Phase 1A policy, not schema. Phase 1B -resume/suggestion tables are intentionally not designed here. +**Status**: `profiles`, `education_entries`, `work_experience`, +`profile_section_revisions`, and `profile_section_reviews` are implemented and pgTAP-tested. +All other rows in this profile-domain section remain proposed Phase 1A policy, not schema. Phase +1B resume/suggestion tables are intentionally not designed here. Every table's owner column is literally named `user_id` (see [AGENTS.md](../AGENTS.md)) — child tables reference the owning user via a direct `user_id` foreign key to `profiles.user_id`, never @@ -71,7 +71,8 @@ once per statement rather than once per row — the currently-recommended, non-d | Table(s) | Category / owner | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation and ownership-bypass test | Sensitivity | |---|---|---|---|---|---|---|---|---|---|---| | `profiles` | User-owned root row; direct `user_id` references Auth user | Own only | Own only | Own only | **No direct browser DELETE** | Trusted service/administrative code may have broader maintenance, export, repair, and coordinated-account-deletion access. Root-profile deletion is permitted only in an explicit trusted workflow that coordinates the Auth user, database records, Storage objects, confirmation, and any export requirement — never as a normal browser or routine service-role path. | `(select auth.uid()) = user_id` for SELECT/INSERT/UPDATE; no browser DELETE grant/policy | No | Owner SELECT/INSERT/UPDATE; DELETE fails even for owner; cross-user and forged-owner denial; service deletion is exercised only through the trusted workflow | High | -| `education_entries`, `work_experience`, `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | User-owned; direct `user_id` references `profiles(user_id)` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) | +| `education_entries`, `work_experience` | **Implemented** user-owned history/evidence; direct `user_id` references `profiles(user_id)` | Own only | Own only | Own only, content columns only | Own only | Explicit full maintenance access | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite, provenance, and timestamp-write denial | High | +| `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | Proposed user-owned; direct `user_id` references `profiles(user_id)` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) | | `profile_section_reviews` | User-owned deliberate review; composite key `(user_id, section_key)` | Own only | **No direct browser write**; restricted RPC only | **No direct browser write**; restricted RPC only | **No direct browser write** | Full | Direct owner SELECT; `review_profile_section` derives `auth.uid()` and locks the current revision | No | User cannot forge an owner/key/revision; RPC writes only the locked current revision | High | | `profile_section_revisions` | User-owned, database-maintained freshness metadata | Own only | No direct browser write | No direct browser write | No direct browser write | Trusted migration/mutation mechanism only | Direct owner SELECT; no browser mutation grant | No | User can read only own revision; browser cannot forge/increment/rewrite revision; relevant insert/update/delete increments it atomically | Medium | | `preference_locations`, `user_target_engineering_areas`, `user_target_role_types` | User-owned relation; direct `user_id` | Own only | Own only | Own only where applicable | Own only | Full | Direct owner predicate plus composite parent `(user_id, parent_id)` validation | No | Owner CRUD; all cross-user denials; forged owner; cannot attach a valid user A row to B's preferences/profile | Medium | diff --git a/docs/SECURITY_AND_PRIVACY.md b/docs/SECURITY_AND_PRIVACY.md index fe21500..6577a61 100644 --- a/docs/SECURITY_AND_PRIVACY.md +++ b/docs/SECURITY_AND_PRIVACY.md @@ -46,12 +46,15 @@ SELECT/INSERT/UPDATE/DELETE permissions, the expected RLS predicate, anonymous-a the isolation test each row requires. This section states the *principle*; that document is the *checklist* a migration must satisfy. -**Implemented Phase 1A profile-core hardening**: section revisions and reviews are browser -read-only. The only review mutation is a narrowly granted `SECURITY DEFINER` RPC that derives the -caller from `auth.uid()`, locks that user's current revision, and records that exact value. Primary -education selection likewise uses a narrowly granted ownership-checking RPC; browser clients do -not have column privilege to set `is_primary` directly. Every privileged function fixes its search -path, revokes default `PUBLIC` execution, and grants only the browser RPCs to `authenticated`. +**Implemented Phase 1A profile hardening**: section revisions and reviews are browser read-only. +The only review mutation is a narrowly granted `SECURITY DEFINER` RPC that derives the caller from +`auth.uid()`, locks that user's current revision, and records that exact value. The implemented +experience table follows the same direct-ownership RLS model, with column grants that deny browser +rewrites of owner, provenance, and timestamps. Its mutation triggers advance only the `experience` +freshness revision on meaningful content changes. Primary-education selection likewise uses a +narrowly granted ownership-checking RPC; browser clients do not have column privilege to set +`is_primary` directly. Every privileged function fixes its search path, revokes default `PUBLIC` +execution, and grants only the browser RPCs to `authenticated`. **Row-level data isolation for future public users**: because the schema is already `user_id`- scoped and RLS-enforced, enabling additional user accounts later is a matter of allowing sign-up — diff --git a/supabase/fixtures/migration/assert_work_experience_migration.sql b/supabase/fixtures/migration/assert_work_experience_migration.sql new file mode 100644 index 0000000..c8bc22e --- /dev/null +++ b/supabase/fixtures/migration/assert_work_experience_migration.sql @@ -0,0 +1,54 @@ +do $$ +declare + future_user_id uuid := '70000000-0000-0000-0000-000000000003'; +begin + if (select headline from public.profiles where user_id = '70000000-0000-0000-0000-000000000001') <> 'Current fixture A revised' then + raise exception 'existing profile data changed'; + end if; + if (select degree from public.education_entries where user_id = '70000000-0000-0000-0000-000000000001') <> 'BSc' then + raise exception 'existing education data changed'; + end if; + if (select content_revision from public.profile_section_revisions where user_id = '70000000-0000-0000-0000-000000000001' and section_key = 'basic_profile') <> 2 then + raise exception 'basic-profile revision changed'; + end if; + if (select content_revision from public.profile_section_revisions where user_id = '70000000-0000-0000-0000-000000000001' and section_key = 'education') <> 2 then + raise exception 'education revision changed'; + end if; + if (select reviewed_content_revision from public.profile_section_reviews where user_id = '70000000-0000-0000-0000-000000000001' and section_key = 'basic_profile') <> 2 then + raise exception 'current basic-profile review changed'; + end if; + if (select reviewed_content_revision from public.profile_section_reviews where user_id = '70000000-0000-0000-0000-000000000001' and section_key = 'education') <> 1 then + raise exception 'stale education review changed'; + end if; + if (select count(*) from public.profile_section_revisions where section_key = 'experience') <> 2 then + raise exception 'existing profiles do not have exactly one experience baseline'; + end if; + if exists (select 1 from public.profile_section_revisions where section_key = 'experience' and content_revision <> 0) then + raise exception 'experience baseline was not neutral'; + end if; + if exists (select 1 from public.profile_section_reviews where section_key = 'experience') then + raise exception 'migration created experience reviews'; + end if; + if exists (select 1 from public.work_experience) then + raise exception 'migration invented experience records'; + end if; + + insert into auth.users (id, aud, role, email) + values (future_user_id, 'authenticated', 'authenticated', 'future-profile@example.test'); + insert into public.profiles (user_id, headline) + values (future_user_id, 'Future profile'); + + if (select count(*) from public.profile_section_revisions where user_id = future_user_id) <> 3 then + raise exception 'new profile did not initialize all three section revisions'; + end if; + if (select content_revision from public.profile_section_revisions where user_id = future_user_id and section_key = 'basic_profile') <> 1 then + raise exception 'new profile basic revision is not advanced'; + end if; + if exists ( + select 1 from public.profile_section_revisions + where user_id = future_user_id and section_key in ('education', 'experience') and content_revision <> 0 + ) then + raise exception 'new profile non-basic revisions are not neutral'; + end if; +end; +$$; diff --git a/supabase/fixtures/migration/current_profile_fixture.sql b/supabase/fixtures/migration/current_profile_fixture.sql new file mode 100644 index 0000000..7d822df --- /dev/null +++ b/supabase/fixtures/migration/current_profile_fixture.sql @@ -0,0 +1,32 @@ +-- Synthetic current-main data inserted after resetting through the merged +-- profile-core/education migration. It exercises preservation of both current +-- and stale section reviews before the experience migration is applied. +do $$ +begin + insert into auth.users (id, aud, role, email) values + ('70000000-0000-0000-0000-000000000001', 'authenticated', 'authenticated', 'current-a@example.test'), + ('70000000-0000-0000-0000-000000000002', 'authenticated', 'authenticated', 'current-b@example.test'); + + insert into public.profiles (user_id, headline) values + ('70000000-0000-0000-0000-000000000001', 'Current fixture A'), + ('70000000-0000-0000-0000-000000000002', 'Current fixture B'); + + update public.profiles + set headline = 'Current fixture A revised' + where user_id = '70000000-0000-0000-0000-000000000001'; + + insert into public.education_entries (user_id, institution, education_status) + values ('70000000-0000-0000-0000-000000000001', 'Current Fixture University', 'current'); + + insert into public.profile_section_reviews ( + user_id, section_key, reviewed_content_revision, reviewed_at, created_at, updated_at + ) values + ('70000000-0000-0000-0000-000000000001', 'basic_profile', 2, now(), now(), now()), + ('70000000-0000-0000-0000-000000000001', 'education', 1, now(), now(), now()), + ('70000000-0000-0000-0000-000000000002', 'education', 0, now(), now(), now()); + + update public.education_entries + set degree = 'BSc' + where user_id = '70000000-0000-0000-0000-000000000001'; +end; +$$; diff --git a/supabase/migrations/20260803090000_work_experience.sql b/supabase/migrations/20260803090000_work_experience.sql new file mode 100644 index 0000000..968a852 --- /dev/null +++ b/supabase/migrations/20260803090000_work_experience.sql @@ -0,0 +1,297 @@ +-- Phase 1A: manual work, internship, research, volunteering, and leadership +-- experience. This migration is forward-only and does not infer or create +-- experience records for existing profiles. + +-- --------------------------------------------------------------------------- +-- Experience schema +-- --------------------------------------------------------------------------- + +create table public.work_experience ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references public.profiles (user_id) on delete cascade, + experience_kind text not null, + organization text not null, + role text not null, + location text, + start_year smallint not null, + start_month smallint, + end_year smallint, + end_month smallint, + is_current boolean not null default false, + description text, + created_via text not null default 'manual', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_confirmed_at timestamptz not null default now(), + constraint work_experience_user_id_id_key unique (user_id, id), + constraint work_experience_kind_check check ( + experience_kind in ( + 'employment', 'internship', 'research', 'volunteering', + 'student_leadership', 'other' + ) + ), + constraint work_experience_organization_check check ( + btrim(organization) <> '' and char_length(organization) <= 200 + ), + constraint work_experience_role_check check ( + btrim(role) <> '' and char_length(role) <= 160 + ), + constraint work_experience_location_check check ( + location is null or (btrim(location) <> '' and char_length(location) <= 160) + ), + constraint work_experience_description_check check ( + description is null or (btrim(description) <> '' and char_length(description) <= 2000) + ), + constraint work_experience_start_year_check check ( + start_year between 1900 and 2100 + ), + constraint work_experience_start_month_check check ( + start_month is null or start_month between 1 and 12 + ), + constraint work_experience_end_year_check check ( + end_year is null or end_year between 1900 and 2100 + ), + constraint work_experience_end_month_check check ( + end_month is null or end_month between 1 and 12 + ), + constraint work_experience_end_month_requires_year_check check ( + end_month is null or end_year is not null + ), + constraint work_experience_current_end_period_check check ( + not is_current or (end_year is null and end_month is null) + ), + constraint work_experience_period_order_check check ( + end_year is null + or end_year > start_year + or ( + end_year = start_year + and (start_month is null or end_month is null or end_month >= start_month) + ) + ), + constraint work_experience_created_via_check check ( + created_via in ('manual', 'migration') + ) +); + +comment on table public.work_experience is + 'Manual professional and extracurricular experience, owned directly through user_id.'; +comment on column public.work_experience.start_month is + 'Optional month precision; a missing month means the user did not claim a month.'; +comment on column public.work_experience.last_confirmed_at is + 'Last explicit manual experience-content creation or edit.'; + +create index work_experience_user_chronology_idx + on public.work_experience ( + user_id, + is_current desc, + start_year desc, + start_month desc, + created_at desc + ); + +-- --------------------------------------------------------------------------- +-- Section state: preserve existing counters and reviews, adding a neutral +-- experience baseline for each existing profile. +-- --------------------------------------------------------------------------- + +alter table public.profile_section_revisions + drop constraint profile_section_revisions_section_key_check, + add constraint profile_section_revisions_section_key_check + check (section_key in ('basic_profile', 'education', 'experience')); + +alter table public.profile_section_reviews + drop constraint profile_section_reviews_section_key_check, + add constraint profile_section_reviews_section_key_check + check (section_key in ('basic_profile', 'education', 'experience')); + +insert into public.profile_section_revisions (user_id, section_key, content_revision) +select user_id, 'experience', 0 +from public.profiles +on conflict (user_id, section_key) do nothing; + +-- --------------------------------------------------------------------------- +-- Hardened triggers and constrained review RPC +-- --------------------------------------------------------------------------- + +create or replace function public.profiles_after_content_change() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if tg_op = 'INSERT' then + insert into public.profile_section_revisions (user_id, section_key, content_revision) + values + (new.user_id, 'basic_profile', 0), + (new.user_id, 'education', 0), + (new.user_id, 'experience', 0) + on conflict (user_id, section_key) do nothing; + perform public.increment_profile_section_revision(new.user_id, 'basic_profile'); + elsif new.preferred_name is distinct from old.preferred_name + or new.headline is distinct from old.headline then + perform public.increment_profile_section_revision(new.user_id, 'basic_profile'); + end if; + return null; +end; +$$; + +create function public.work_experience_before_write() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + content_changed boolean; +begin + if tg_op = 'INSERT' then + perform public.raise_profile_content_validation(new.organization, 'organization', 200); + perform public.raise_profile_content_validation(new.role, 'role', 160); + perform public.raise_profile_content_validation(new.location, 'location', 160); + perform public.raise_profile_content_validation(new.description, 'description', 2000); + return new; + end if; + + if new.organization is distinct from old.organization then + perform public.raise_profile_content_validation(new.organization, 'organization', 200); + end if; + if new.role is distinct from old.role then + perform public.raise_profile_content_validation(new.role, 'role', 160); + end if; + if new.location is distinct from old.location then + perform public.raise_profile_content_validation(new.location, 'location', 160); + end if; + if new.description is distinct from old.description then + perform public.raise_profile_content_validation(new.description, 'description', 2000); + end if; + + content_changed := new.experience_kind is distinct from old.experience_kind + or new.organization is distinct from old.organization + or new.role is distinct from old.role + or new.location is distinct from old.location + or new.start_year is distinct from old.start_year + or new.start_month is distinct from old.start_month + or new.end_year is distinct from old.end_year + or new.end_month is distinct from old.end_month + or new.is_current is distinct from old.is_current + or new.description is distinct from old.description; + + if content_changed then + new.updated_at := now(); + new.last_confirmed_at := now(); + else + new.user_id := old.user_id; + new.created_at := old.created_at; + new.created_via := old.created_via; + new.updated_at := old.updated_at; + new.last_confirmed_at := old.last_confirmed_at; + end if; + return new; +end; +$$; + +create function public.work_experience_after_content_change() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + owner_id uuid; +begin + owner_id := case when tg_op = 'DELETE' then old.user_id else new.user_id end; + if tg_op <> 'UPDATE' + or new.experience_kind is distinct from old.experience_kind + or new.organization is distinct from old.organization + or new.role is distinct from old.role + or new.location is distinct from old.location + or new.start_year is distinct from old.start_year + or new.start_month is distinct from old.start_month + or new.end_year is distinct from old.end_year + or new.end_month is distinct from old.end_month + or new.is_current is distinct from old.is_current + or new.description is distinct from old.description then + perform public.increment_profile_section_revision(owner_id, 'experience'); + end if; + return null; +end; +$$; + +create trigger work_experience_before_write +before insert or update on public.work_experience +for each row execute function public.work_experience_before_write(); + +create trigger work_experience_after_content_change +after insert or update or delete on public.work_experience +for each row execute function public.work_experience_after_content_change(); + +create or replace function public.review_profile_section(requested_section_key text) +returns bigint +language plpgsql +security definer +set search_path = '' +as $$ +declare + caller_id uuid := auth.uid(); + current_revision bigint; +begin + if caller_id is null then + raise exception using errcode = '28000', message = 'authentication required'; + end if; + if requested_section_key not in ('basic_profile', 'education', 'experience') then + raise exception using errcode = '22023', message = 'unknown profile section'; + end if; + + select content_revision into current_revision + from public.profile_section_revisions + where user_id = caller_id and section_key = requested_section_key + for update; + if not found then + raise exception using errcode = 'P0002', message = 'profile section does not exist'; + end if; + + insert into public.profile_section_reviews ( + user_id, section_key, reviewed_content_revision, reviewed_at, created_at, updated_at + ) values (caller_id, requested_section_key, current_revision, now(), now(), now()) + on conflict (user_id, section_key) do update + set reviewed_content_revision = excluded.reviewed_content_revision, + reviewed_at = excluded.reviewed_at, + updated_at = excluded.updated_at; + return current_revision; +end; +$$; + +revoke all on function public.work_experience_before_write() from public, anon, authenticated; +revoke all on function public.work_experience_after_content_change() from public, anon, authenticated; +revoke all on function public.review_profile_section(text) from public, anon, authenticated; +grant execute on function public.review_profile_section(text) to authenticated; + +-- --------------------------------------------------------------------------- +-- Explicit grants and RLS +-- --------------------------------------------------------------------------- + +alter table public.work_experience enable row level security; +revoke all on public.work_experience from anon, authenticated, service_role; +grant select on public.work_experience to authenticated; +grant insert ( + user_id, experience_kind, organization, role, location, + start_year, start_month, end_year, end_month, is_current, description +) on public.work_experience to authenticated; +grant update ( + experience_kind, organization, role, location, + start_year, start_month, end_year, end_month, is_current, description +) on public.work_experience to authenticated; +grant delete on public.work_experience to authenticated; +grant select, insert, update, delete on public.work_experience to service_role; + +create policy work_experience_select_own on public.work_experience + for select to authenticated using ((select auth.uid()) = user_id); +create policy work_experience_insert_own on public.work_experience + for insert to authenticated with check ((select auth.uid()) = user_id); +create policy work_experience_update_own on public.work_experience + for update to authenticated + using ((select auth.uid()) = user_id) + with check ((select auth.uid()) = user_id); +create policy work_experience_delete_own on public.work_experience + for delete to authenticated using ((select auth.uid()) = user_id); diff --git a/supabase/scripts/work-experience-migration-compatibility-test.sh b/supabase/scripts/work-experience-migration-compatibility-test.sh new file mode 100755 index 0000000..b380bf5 --- /dev/null +++ b/supabase/scripts/work-experience-migration-compatibility-test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercise the current merged profile schema and data before applying only the +# new forward migration. This complements the Phase 0 compatibility test. +supabase db reset --local --version 20260801213000 --no-seed +supabase db query --local --file supabase/fixtures/migration/current_profile_fixture.sql +supabase migration up --local +supabase db query --local --file supabase/fixtures/migration/assert_work_experience_migration.sql +echo 'Profile-core-to-work-experience migration compatibility test passed.' diff --git a/supabase/tests/database/000_structure.test.sql b/supabase/tests/database/000_structure.test.sql index b357470..20be575 100644 --- a/supabase/tests/database/000_structure.test.sql +++ b/supabase/tests/database/000_structure.test.sql @@ -2,7 +2,7 @@ -- policy existence, and grant existence (including the absence of any -- grant to anon). No fixture data needed; this only inspects catalogs. begin; -select plan(58); +select plan(63); -- profiles ------------------------------------------------------------------ @@ -102,6 +102,11 @@ select table_privs_are('public', 'profile_section_reviews', 'anon', array[]::tex select has_index('public', 'education_entries', 'education_entries_one_primary_per_user_idx', 'partial primary education index exists'); select has_function('public', 'review_profile_section', array['text'], 'review RPC exists'); select has_function('public', 'set_primary_education', array['uuid'], 'primary selection RPC exists'); +select has_table('public', 'work_experience', 'work_experience table exists'); +select has_column('public', 'work_experience', 'user_id', 'work_experience owns directly through user_id'); +select col_is_pk('public', 'work_experience', 'id', 'work_experience.id is the primary key'); +select fk_ok('public', 'work_experience', 'user_id', 'public', 'profiles', 'user_id', 'work_experience.user_id references profiles.user_id'); +select has_index('public', 'work_experience', 'work_experience_user_chronology_idx', 'experience chronology index exists'); select * from finish(); rollback; diff --git a/supabase/tests/database/040_privileged_function_security.test.sql b/supabase/tests/database/040_privileged_function_security.test.sql index 3e71ae5..f57a5b2 100644 --- a/supabase/tests/database/040_privileged_function_security.test.sql +++ b/supabase/tests/database/040_privileged_function_security.test.sql @@ -1,7 +1,7 @@ -- Every Phase 1A function is deliberately SECURITY DEFINER with an empty -- search_path. Browser roles may execute only the two constrained RPCs. begin; -select plan(40); +select plan(50); with functions(signature, label, browser_callable) as ( values @@ -11,6 +11,8 @@ with functions(signature, label, browser_callable) as ( ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision', false), ('public.profiles_after_content_change()', 'profiles_after_content_change', false), ('public.education_entries_after_content_change()', 'education_entries_after_content_change', false), + ('public.work_experience_before_write()', 'work_experience_before_write', false), + ('public.work_experience_after_content_change()', 'work_experience_after_content_change', false), ('public.review_profile_section(text)', 'review_profile_section', true), ('public.set_primary_education(uuid)', 'set_primary_education', true) ) @@ -24,6 +26,8 @@ with functions(signature, label) as ( ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision'), ('public.profiles_after_content_change()', 'profiles_after_content_change'), ('public.education_entries_after_content_change()', 'education_entries_after_content_change'), + ('public.work_experience_before_write()', 'work_experience_before_write'), + ('public.work_experience_after_content_change()', 'work_experience_after_content_change'), ('public.review_profile_section(text)', 'review_profile_section'), ('public.set_primary_education(uuid)', 'set_primary_education') ) @@ -40,6 +44,8 @@ with functions(signature, label) as ( ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision'), ('public.profiles_after_content_change()', 'profiles_after_content_change'), ('public.education_entries_after_content_change()', 'education_entries_after_content_change'), + ('public.work_experience_before_write()', 'work_experience_before_write'), + ('public.work_experience_after_content_change()', 'work_experience_after_content_change'), ('public.review_profile_section(text)', 'review_profile_section'), ('public.set_primary_education(uuid)', 'set_primary_education') ) @@ -60,6 +66,8 @@ with functions(signature, label) as ( ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision'), ('public.profiles_after_content_change()', 'profiles_after_content_change'), ('public.education_entries_after_content_change()', 'education_entries_after_content_change'), + ('public.work_experience_before_write()', 'work_experience_before_write'), + ('public.work_experience_after_content_change()', 'work_experience_after_content_change'), ('public.review_profile_section(text)', 'review_profile_section'), ('public.set_primary_education(uuid)', 'set_primary_education') ) @@ -77,6 +85,8 @@ with functions(signature, label, browser_callable) as ( ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision', false), ('public.profiles_after_content_change()', 'profiles_after_content_change', false), ('public.education_entries_after_content_change()', 'education_entries_after_content_change', false), + ('public.work_experience_before_write()', 'work_experience_before_write', false), + ('public.work_experience_after_content_change()', 'work_experience_after_content_change', false), ('public.review_profile_section(text)', 'review_profile_section', true), ('public.set_primary_education(uuid)', 'set_primary_education', true) ) diff --git a/supabase/tests/database/060_work_experience_rls.test.sql b/supabase/tests/database/060_work_experience_rls.test.sql new file mode 100644 index 0000000..5bd53ea --- /dev/null +++ b/supabase/tests/database/060_work_experience_rls.test.sql @@ -0,0 +1,292 @@ +-- Work-experience structure, validation, RLS, review freshness, and cascade +-- safety. All fixture data is synthetic and this transaction is rolled back. +begin; +select plan(58); + +-- Structure ----------------------------------------------------------------- + +select has_table('public', 'work_experience', 'work_experience table exists'); +select is( + ( + select array_agg(attname || ':' || format_type(atttypid, atttypmod) order by attnum) + from pg_attribute + where attrelid = 'public.work_experience'::regclass and attnum > 0 and not attisdropped + ), + array[ + 'id:uuid', 'user_id:uuid', 'experience_kind:text', 'organization:text', + 'role:text', 'location:text', 'start_year:smallint', 'start_month:smallint', + 'end_year:smallint', 'end_month:smallint', 'is_current:boolean', + 'description:text', 'created_via:text', 'created_at:timestamp with time zone', + 'updated_at:timestamp with time zone', 'last_confirmed_at:timestamp with time zone' + ], + 'work_experience has exactly the documented columns and types' +); +select col_is_pk('public', 'work_experience', 'id', 'work_experience.id is the primary key'); +select fk_ok('public', 'work_experience', 'user_id', 'public', 'profiles', 'user_id', 'work_experience.user_id directly references profiles.user_id'); +select ok( + exists (select 1 from pg_constraint where conname = 'work_experience_user_id_id_key'), + 'work_experience has the future-safe (user_id, id) unique constraint' +); +select has_index('public', 'work_experience', 'work_experience_user_chronology_idx', 'owner chronology index exists'); +select is((select relrowsecurity from pg_class where oid = 'public.work_experience'::regclass), true, 'RLS is enabled on work_experience'); +select policies_are( + 'public', 'work_experience', + array['work_experience_select_own', 'work_experience_insert_own', 'work_experience_update_own', 'work_experience_delete_own'], + 'work_experience has exactly the own-row policies' +); +select is( + has_table_privilege('authenticated', 'public.work_experience', 'SELECT, DELETE') + and has_column_privilege('authenticated', 'public.work_experience', 'organization', 'INSERT, UPDATE') + and not has_column_privilege('authenticated', 'public.work_experience', 'user_id', 'UPDATE'), + true, + 'authenticated has intended content privileges without ownership rewrite access' +); +select is( + not has_column_privilege('authenticated', 'public.work_experience', 'created_via', 'INSERT, UPDATE') + and not has_column_privilege('authenticated', 'public.work_experience', 'updated_at', 'INSERT, UPDATE') + and not has_column_privilege('authenticated', 'public.work_experience', 'last_confirmed_at', 'INSERT, UPDATE'), + true, + 'authenticated cannot forge provenance or timestamps' +); +select table_privs_are('public', 'work_experience', 'service_role', array['SELECT', 'INSERT', 'UPDATE', 'DELETE'], 'service_role has explicit maintenance access'); +select table_privs_are('public', 'work_experience', 'anon', array[]::text[], 'anon has no work_experience grants'); +select ok(exists (select 1 from pg_trigger where tgrelid = 'public.work_experience'::regclass and tgname = 'work_experience_before_write'), 'before-write trigger exists'); +select ok(exists (select 1 from pg_trigger where tgrelid = 'public.work_experience'::regclass and tgname = 'work_experience_after_content_change'), 'revision trigger exists'); + +-- Fixtures ------------------------------------------------------------------ + +insert into auth.users (id, aud, role, email) values + ('81000000-0000-0000-0000-000000000001', 'authenticated', 'authenticated', 'experience-a@example.test'), + ('81000000-0000-0000-0000-000000000002', 'authenticated', 'authenticated', 'experience-b@example.test'), + ('81000000-0000-0000-0000-000000000003', 'authenticated', 'authenticated', 'experience-c@example.test'), + ('81000000-0000-0000-0000-000000000004', 'authenticated', 'authenticated', 'experience-d@example.test'); + +insert into public.profiles (user_id, headline) values + ('81000000-0000-0000-0000-000000000001', 'Experience A'), + ('81000000-0000-0000-0000-000000000002', 'Experience B'), + ('81000000-0000-0000-0000-000000000003', 'Experience C'); + +-- Owner CRUD and validation ------------------------------------------------- + +reset role; +select set_config('request.jwt.claims', json_build_object('sub', '81000000-0000-0000-0000-000000000001', 'role', 'authenticated')::text, true); +set role authenticated; + +select lives_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000001', 'internship', 'Synthetic Labs', 'Engineering Intern', 2025) $$, + 'owner can insert a year-only experience' +); +select is( + (select start_month from public.work_experience where organization = 'Synthetic Labs'), + null::smallint, + 'year-only experience preserves absent month precision' +); +select lives_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year, end_year, end_month) values ('81000000-0000-0000-0000-000000000001', 'research', 'Synthetic Research Group', 'Student Researcher', 2025, 2025, 1) $$, + 'same-year partial dates with an unknown start month are accepted' +); +select lives_ok( + $$ update public.work_experience set role = 'Research Assistant' where organization = 'Synthetic Research Group' $$, + 'owner can update content' +); + +reset role; +select set_config('request.jwt.claims', json_build_object('sub', '81000000-0000-0000-0000-000000000002', 'role', 'authenticated')::text, true); +set role authenticated; +select lives_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000002', 'volunteering', 'Synthetic B Organization', 'Volunteer', 2024) $$, + 'second owner can insert their own experience' +); + +reset role; +select set_config('request.jwt.claims', json_build_object('sub', '81000000-0000-0000-0000-000000000001', 'role', 'authenticated')::text, true); +set role authenticated; + +select is_empty( + $$ select 1 from public.work_experience where user_id = '81000000-0000-0000-0000-000000000002' $$, + 'owner cannot select another user''s experience' +); +with updated as ( + update public.work_experience set role = 'Hacked' where user_id = '81000000-0000-0000-0000-000000000002' returning 1 +) +select is((select count(*)::int from updated), 0, 'owner cannot update another user''s experience'); +with deleted as ( + delete from public.work_experience where user_id = '81000000-0000-0000-0000-000000000002' returning 1 +) +select is((select count(*)::int from deleted), 0, 'owner cannot delete another user''s experience'); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000002', 'employment', 'Forged', 'Forged', 2025) $$, + '42501'::char(5), null, 'owner cannot insert another user''s experience' +); +select throws_ok( + $$ update public.work_experience set user_id = '81000000-0000-0000-0000-000000000002' where organization = 'Synthetic Labs' $$, + '42501'::char(5), null, 'owner cannot rewrite experience ownership' +); +select throws_ok( + $$ update public.work_experience set created_via = 'migration' where organization = 'Synthetic Labs' $$, + '42501'::char(5), null, 'owner cannot rewrite provenance' +); +select throws_ok( + $$ update public.work_experience set updated_at = now() where organization = 'Synthetic Labs' $$, + '42501'::char(5), null, 'owner cannot rewrite timestamps' +); + +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000001', 'unsupported', 'Kind Check', 'Role', 2025) $$, + '23514'::char(5), null, 'unsupported experience kind is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000001', 'employment', ' ', 'Role', 2025) $$, + '23514'::char(5), null, 'blank organization is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', ' ', 2025) $$, + '23514'::char(5), null, 'blank role is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000001', 'employment', repeat('x', 201), 'Role', 2025) $$, + '23514'::char(5), null, 'overlength organization is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year, description) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', 'Role', 2025, repeat('x', 2001)) $$, + '23514'::char(5), null, 'overlength description is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year, start_month) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', 'Role', 2025, 13) $$, + '23514'::char(5), null, 'invalid month is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', 'Role', 1800) $$, + '23514'::char(5), null, 'invalid year is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year, end_month) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', 'Role', 2025, 1) $$, + '23514'::char(5), null, 'end month without end year is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year, end_year, is_current) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', 'Role', 2025, 2026, true) $$, + '23514'::char(5), null, 'current experience cannot have an end period' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year, end_year) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', 'Role', 2025, 2024) $$, + '23514'::char(5), null, 'end year before start year is rejected' +); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year, start_month, end_year, end_month) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Organization', 'Role', 2025, 7, 2025, 6) $$, + '23514'::char(5), null, 'same-year known months out of order are rejected' +); + +-- Revision and review state ------------------------------------------------- + +select ok( + (select content_revision from public.profile_section_revisions where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience') > 0, + 'experience inserts advance the experience revision' +); +select lives_ok($$ select public.review_profile_section('experience') $$, 'owner can review experience at its current revision'); +select is( + (select reviewed_content_revision from public.profile_section_reviews where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience'), + (select content_revision from public.profile_section_revisions where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience'), + 'experience review records the current revision' +); +select lives_ok( + $$ update public.work_experience set location = 'Bremen' where organization = 'Synthetic Labs' $$, + 'meaningful experience update succeeds' +); +select isnt( + (select reviewed_content_revision from public.profile_section_reviews where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience'), + (select content_revision from public.profile_section_revisions where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience'), + 'meaningful experience update makes the review stale' +); +select lives_ok( + $$ delete from public.work_experience where organization = 'Synthetic Research Group' $$, + 'owner can delete their experience' +); +select isnt( + (select reviewed_content_revision from public.profile_section_reviews where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience'), + (select content_revision from public.profile_section_revisions where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience'), + 'experience deletion keeps the prior review stale' +); + +reset role; +set role service_role; +select set_config( + 'test.experience_revision_before_metadata', + ( + select content_revision::text from public.profile_section_revisions + where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience' + ), + true +); +select lives_ok( + $$ update public.work_experience set created_via = 'migration' where organization = 'Synthetic Labs' $$, + 'service-role metadata update is harmlessly accepted' +); +select is( + (select content_revision from public.profile_section_revisions where user_id = '81000000-0000-0000-0000-000000000001' and section_key = 'experience'), + current_setting('test.experience_revision_before_metadata')::bigint, + 'metadata-only update does not advance the revision' +); + +reset role; +select set_config('request.jwt.claims', json_build_object('sub', '81000000-0000-0000-0000-000000000003', 'role', 'authenticated')::text, true); +set role authenticated; +select lives_ok($$ select public.review_profile_section('experience') $$, 'an empty experience section can be reviewed'); +select is( + (select reviewed_content_revision from public.profile_section_reviews where user_id = '81000000-0000-0000-0000-000000000003' and section_key = 'experience'), + 0::bigint, + 'empty experience review is current at baseline revision zero' +); + +reset role; +select set_config('request.jwt.claims', json_build_object('sub', '81000000-0000-0000-0000-000000000001', 'role', 'authenticated')::text, true); +set role authenticated; +select throws_ok($$ select public.review_profile_section('projects') $$, '22023'::char(5), null, 'unknown review section is rejected'); +reset role; +select set_config('request.jwt.claims', '', true); +set role authenticated; +select throws_ok($$ select public.review_profile_section('experience') $$, '28000'::char(5), null, 'review requires an authenticated caller identity'); + +reset role; +select set_config('request.jwt.claims', json_build_object('sub', '81000000-0000-0000-0000-000000000001', 'role', 'authenticated')::text, true); +set role authenticated; +select throws_ok( + $$ insert into public.profile_section_revisions (user_id, section_key, content_revision) values ('81000000-0000-0000-0000-000000000001', 'experience', 99) $$, + '42501'::char(5), null, 'browser cannot forge experience revisions' +); +select throws_ok( + $$ insert into public.profile_section_reviews (user_id, section_key, reviewed_content_revision) values ('81000000-0000-0000-0000-000000000001', 'experience', 99) $$, + '42501'::char(5), null, 'browser cannot forge experience reviews' +); + +reset role; +set role anon; +select throws_ok($$ select 1 from public.work_experience $$, '42501'::char(5), null, 'anonymous experience SELECT is denied at grants'); +select throws_ok( + $$ insert into public.work_experience (user_id, experience_kind, organization, role, start_year) values ('81000000-0000-0000-0000-000000000001', 'employment', 'Anon', 'Anon', 2025) $$, + '42501'::char(5), null, 'anonymous experience INSERT is denied at grants' +); + +reset role; +set role service_role; +select is( + (select count(*)::int from public.work_experience where user_id in ('81000000-0000-0000-0000-000000000001', '81000000-0000-0000-0000-000000000002')), + 2, + 'service-role can read trusted maintenance rows' +); +select lives_ok( + $$ delete from public.profiles where user_id = '81000000-0000-0000-0000-000000000002' $$, + 'trusted profile deletion remains cascade-safe with experience rows' +); +select lives_ok( + $$ insert into public.profiles (user_id, headline) values ('81000000-0000-0000-0000-000000000004', 'Experience D') $$, + 'new profile creation succeeds after the experience migration' +); +select is( + (select content_revision from public.profile_section_revisions where user_id = '81000000-0000-0000-0000-000000000004' and section_key = 'experience'), + 0::bigint, + 'new profiles initialize experience at revision zero' +); + +reset role; +select * from finish(); +rollback; From e94531e6197985af60996c4eda0f136146b219ea Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Mon, 3 Aug 2026 11:08:09 +0300 Subject: [PATCH 2/3] test: cover work experience API security Exercise the work-experience REST path with real Auth, JWT, PostgREST, RLS, validation, revision freshness, concurrent updates, and verified cleanup. Add the typed client repository boundary used by the experience workflow. --- app/src/lib/experienceRepository.ts | 57 ++++++++++ app/src/lib/profileTypes.ts | 27 ++++- docs/TESTING_STRATEGY.md | 23 ++-- supabase/scripts/api-integration-test.mjs | 125 +++++++++++++++++++++- 4 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 app/src/lib/experienceRepository.ts diff --git a/app/src/lib/experienceRepository.ts b/app/src/lib/experienceRepository.ts new file mode 100644 index 0000000..8f0e56e --- /dev/null +++ b/app/src/lib/experienceRepository.ts @@ -0,0 +1,57 @@ +import { supabase } from './supabaseClient' +import type { ExperienceKind, WorkExperience } from './profileTypes' + +export type ExperienceInput = { + experience_kind: ExperienceKind + organization: string + role: string + location: string | null + start_year: number + start_month: number | null + end_year: number | null + end_month: number | null + is_current: boolean + description: string | null +} + +const columns = + 'id, user_id, experience_kind, organization, role, location, start_year, start_month, end_year, end_month, is_current, description' + +export const experienceRepository = { + async list(userId: string) { + return supabase + .from('work_experience') + .select(columns) + .eq('user_id', userId) + .order('is_current', { ascending: false }) + .order('start_year', { ascending: false }) + .order('start_month', { ascending: false }) + .order('created_at', { ascending: false }) + .returns() + }, + async get(id: string) { + return supabase + .from('work_experience') + .select(columns) + .eq('id', id) + .maybeSingle() + }, + async create(userId: string, input: ExperienceInput) { + return supabase + .from('work_experience') + .insert({ user_id: userId, ...input }) + .select(columns) + .maybeSingle() + }, + async update(id: string, input: ExperienceInput) { + return supabase + .from('work_experience') + .update(input) + .eq('id', id) + .select(columns) + .maybeSingle() + }, + async remove(id: string) { + return supabase.from('work_experience').delete().eq('id', id).select('id') + }, +} diff --git a/app/src/lib/profileTypes.ts b/app/src/lib/profileTypes.ts index eeaa4ca..37c5245 100644 --- a/app/src/lib/profileTypes.ts +++ b/app/src/lib/profileTypes.ts @@ -1,6 +1,16 @@ export type EducationStatus = 'current' | 'completed' | 'paused' | 'withdrawn' | 'unknown' +export type ExperienceKind = + | 'employment' + | 'internship' + | 'research' + | 'volunteering' + | 'student_leadership' + | 'other' + +export type SectionKey = 'basic_profile' | 'education' | 'experience' + export interface ProfileRow { user_id: string preferred_name: string | null @@ -23,8 +33,23 @@ export interface EducationEntry { end_date: string | null } +export interface WorkExperience { + id: string + user_id: string + experience_kind: ExperienceKind + organization: string + role: string + location: string | null + start_year: number + start_month: number | null + end_year: number | null + end_month: number | null + is_current: boolean + description: string | null +} + export interface SectionState { - section_key: 'basic_profile' | 'education' + section_key: SectionKey content_revision: number reviewed_content_revision: number | null } diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md index 7a65d32..ac015be 100644 --- a/docs/TESTING_STRATEGY.md +++ b/docs/TESTING_STRATEGY.md @@ -72,14 +72,15 @@ each rule gets explicit positive and negative test cases. ## Phase 1A profile implementation tests (future) -**Implemented in the first profile-core/education slice**: pgTAP verifies refined profile and -education schema/grants, browser denial of root-profile deletion and direct revision/review/primary -writes, review freshness, primary switching/idempotence, and cascade safety. It also catalog-tests -all eight Phase 1A privileged functions for `SECURITY DEFINER`, an empty hardened search path, and -the exact callable surface: browser clients may execute only the constrained review and -primary-selection RPCs. The real Auth/JWT/PostgREST suite covers column restrictions, education -CRUD, primary switching, review staleness, cross-user denial, and concurrent mutation revision -advancement; later Phase 1A domains remain future work. +**Implemented through the experience slice**: pgTAP verifies refined profile, education, and work +experience schema/grants, browser denial of root-profile deletion and direct revision/review/primary +writes, review freshness, primary switching/idempotence, experience partial-date validation, and +cascade safety. It catalog-tests all ten current Phase 1A privileged functions for +`SECURITY DEFINER`, an empty hardened search path, and the exact callable surface: browser clients +may execute only the constrained review and primary-selection RPCs. The real Auth/JWT/PostgREST +suite covers column restrictions, experience and education CRUD, review staleness, cross-user +denial, and concurrent mutation revision advancement; projects and remaining Phase 1A domains +remain future work. The corrective API coverage also explicitly verifies that neither an owner nor another browser user can delete a root profile, and that browser clients cannot directly insert revision or review @@ -93,9 +94,9 @@ status, neutral revisions, no reviews, migration provenance, and nullable migrat timestamps. The frontend component suite covers the user-observable loading, ready, missing, error, retry, -zero-row, review-refresh, and pending-operation states for the basic-profile and education pages. -Those pages never substitute a failed section-state request with “Not reviewed”; their visible -status always comes from the returned revision/review state. +zero-row, review-refresh, and pending-operation states for the basic-profile, education, and +experience pages. Those pages never substitute a failed section-state request with “Not reviewed”; +their visible status always comes from the returned revision/review state. - **Migration structure tests**: exact tables, direct `user_id` ownership, required foreign keys, ownership-safe composite parent references, primary-education partial unique constraint, diff --git a/supabase/scripts/api-integration-test.mjs b/supabase/scripts/api-integration-test.mjs index 9cbde0b..59f72f1 100644 --- a/supabase/scripts/api-integration-test.mjs +++ b/supabase/scripts/api-integration-test.mjs @@ -98,7 +98,7 @@ async function verifyUserFullyRemoved(id) { if (profileRows.length !== 0) { problems.push(`profile row for ${id} still present after user deletion (expected cascade delete)`); } - for (const table of ['education_entries', 'profile_section_revisions', 'profile_section_reviews']) { + for (const table of ['education_entries', 'work_experience', 'profile_section_revisions', 'profile_section_reviews']) { const rows = await adminGetRows(table, id); if (rows.length !== 0) problems.push(`${table} rows for ${id} still present after user deletion (expected cascade delete)`); } @@ -202,6 +202,125 @@ async function main() { }); check('user A cannot delete their own root profile through the browser role', !deleteOwnProfile.ok && deleteOwnProfile.status === 403); + // ---- experience CRUD, validation, and review freshness ----------------- + const reviewEmptyExperience = await rest(tokenA, '/rpc/review_profile_section', { + method: 'POST', body: { requested_section_key: 'experience' }, + }); + const emptyExperienceState = await rest(tokenA, '/profile_section_revisions?section_key=eq.experience&select=content_revision'); + const emptyExperienceReview = await rest(tokenA, '/profile_section_reviews?section_key=eq.experience&select=reviewed_content_revision'); + check('user A can review an intentionally empty experience section', reviewEmptyExperience.ok && emptyExperienceState.ok && emptyExperienceReview.ok && emptyExperienceState.data[0].content_revision === 0 && emptyExperienceReview.data[0].reviewed_content_revision === 0); + + const createExperienceA = await rest(tokenA, '/work_experience', { + method: 'POST', + body: { + user_id: userA.id, + experience_kind: 'research', + organization: 'Synthetic Integration Lab', + role: 'Research Assistant', + start_year: 2025, + start_month: 10, + is_current: true, + description: 'Synthetic public-safe experience fixture.', + }, + prefer: 'return=representation', + }); + const experienceA = createExperienceA.data?.[0]; + check('user A can create experience through the REST API', createExperienceA.ok && experienceA?.experience_kind === 'research' && experienceA?.start_month === 10); + + const experienceReviewAfterCreate = await rest(tokenA, '/profile_section_reviews?section_key=eq.experience&select=reviewed_content_revision'); + const experienceRevisionAfterCreate = await rest(tokenA, '/profile_section_revisions?section_key=eq.experience&select=content_revision'); + check('experience creation makes an empty-section review stale', experienceReviewAfterCreate.ok && experienceRevisionAfterCreate.ok && experienceReviewAfterCreate.data[0].reviewed_content_revision !== experienceRevisionAfterCreate.data[0].content_revision); + + const invalidExperienceKind = await rest(tokenA, '/work_experience', { + method: 'POST', + body: { user_id: userA.id, experience_kind: 'invalid', organization: 'Synthetic', role: 'Role', start_year: 2025 }, + }); + check('invalid experience kind is rejected through the REST API', !invalidExperienceKind.ok && invalidExperienceKind.status >= 400); + + const invalidExperienceDates = await rest(tokenA, '/work_experience', { + method: 'POST', + body: { user_id: userA.id, experience_kind: 'employment', organization: 'Synthetic', role: 'Role', start_year: 2025, start_month: 7, end_year: 2025, end_month: 6 }, + }); + check('invalid experience date ordering is rejected through the REST API', !invalidExperienceDates.ok && invalidExperienceDates.status >= 400); + + const currentWithEndPeriod = await rest(tokenA, '/work_experience', { + method: 'POST', + body: { user_id: userA.id, experience_kind: 'employment', organization: 'Synthetic', role: 'Role', start_year: 2025, end_year: 2026, is_current: true }, + }); + check('current experience with an end period is rejected through the REST API', !currentWithEndPeriod.ok && currentWithEndPeriod.status >= 400); + + const readExperienceA = await rest(tokenA, `/work_experience?id=eq.${experienceA.id}`); + check('user A can read their own experience through the REST API', readExperienceA.ok && readExperienceA.data.length === 1); + + const updateExperienceA = await rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { + method: 'PATCH', body: { location: 'Bremen' }, prefer: 'return=representation', + }); + check('user A can update their own experience through the REST API', updateExperienceA.ok && updateExperienceA.data[0].location === 'Bremen'); + + const forgedExperienceProvenance = await rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { + method: 'PATCH', body: { created_via: 'migration' }, prefer: 'return=representation', + }); + const forgedExperienceTimestamp = await rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { + method: 'PATCH', body: { updated_at: '2025-01-01T00:00:00Z' }, prefer: 'return=representation', + }); + check('user A cannot forge experience provenance or timestamps', !forgedExperienceProvenance.ok && forgedExperienceProvenance.status === 403 && !forgedExperienceTimestamp.ok && forgedExperienceTimestamp.status === 403); + + const reviewExperience = await rest(tokenA, '/rpc/review_profile_section', { + method: 'POST', body: { requested_section_key: 'experience' }, + }); + const experienceReviewCurrent = await rest(tokenA, '/profile_section_reviews?section_key=eq.experience&select=reviewed_content_revision'); + const experienceRevisionCurrent = await rest(tokenA, '/profile_section_revisions?section_key=eq.experience&select=content_revision'); + check('user A can renew their experience review at the current revision', reviewExperience.ok && experienceReviewCurrent.data[0].reviewed_content_revision === experienceRevisionCurrent.data[0].content_revision); + + const revisionBeforeConcurrentExperienceUpdates = await rest(tokenA, '/profile_section_revisions?section_key=eq.experience&select=content_revision'); + const concurrentExperienceUpdates = await Promise.all([ + rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { method: 'PATCH', body: { role: 'Research Engineering Assistant' }, prefer: 'return=representation' }), + rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { method: 'PATCH', body: { description: 'Synthetic updated public-safe experience fixture.' }, prefer: 'return=representation' }), + ]); + const revisionAfterConcurrentExperienceUpdates = await rest(tokenA, '/profile_section_revisions?section_key=eq.experience&select=content_revision'); + const experienceReviewAfterUpdate = await rest(tokenA, '/profile_section_reviews?section_key=eq.experience&select=reviewed_content_revision'); + check('concurrent experience mutations advance revision monotonically without losing increments', concurrentExperienceUpdates.every((result) => result.ok) && revisionAfterConcurrentExperienceUpdates.data[0].content_revision >= revisionBeforeConcurrentExperienceUpdates.data[0].content_revision + 2); + check('later experience mutations make its review stale', experienceReviewAfterUpdate.ok && experienceReviewAfterUpdate.data[0].reviewed_content_revision !== revisionAfterConcurrentExperienceUpdates.data[0].content_revision); + + const zeroRowExperienceUpdate = await rest(tokenA, '/work_experience?id=eq.00000000-0000-0000-0000-000000000000', { + method: 'PATCH', body: { role: 'Missing' }, prefer: 'return=representation', + }); + check('a missing experience update returns zero rows', zeroRowExperienceUpdate.ok && zeroRowExperienceUpdate.data.length === 0); + + const readExperienceAAsB = await rest(tokenB, `/work_experience?id=eq.${experienceA.id}`); + const updateExperienceAAsB = await rest(tokenB, `/work_experience?id=eq.${experienceA.id}`, { + method: 'PATCH', body: { role: 'Hacked' }, prefer: 'return=representation', + }); + const deleteExperienceAAsB = await rest(tokenB, `/work_experience?id=eq.${experienceA.id}`, { + method: 'DELETE', prefer: 'return=representation', + }); + check('user B cannot read, update, or delete user A\'s experience', readExperienceAAsB.ok && readExperienceAAsB.data.length === 0 && updateExperienceAAsB.ok && updateExperienceAAsB.data.length === 0 && deleteExperienceAAsB.ok && deleteExperienceAAsB.data.length === 0); + + const forgedExperienceInsert = await rest(tokenA, '/work_experience', { + method: 'POST', body: { user_id: userB.id, experience_kind: 'internship', organization: 'Forged', role: 'Forged', start_year: 2025 }, + }); + const experienceOwnerRewrite = await rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { + method: 'PATCH', body: { user_id: userB.id }, prefer: 'return=representation', + }); + check('user A cannot forge or rewrite experience ownership', !forgedExperienceInsert.ok && forgedExperienceInsert.status === 403 && !experienceOwnerRewrite.ok && experienceOwnerRewrite.status === 403); + + const reviewExperienceB = await rest(tokenB, '/rpc/review_profile_section', { + method: 'POST', body: { requested_section_key: 'experience' }, + }); + const experienceReviewAAfterB = await rest(tokenA, '/profile_section_reviews?section_key=eq.experience&select=user_id,reviewed_content_revision'); + check('user B\'s experience review changes only user B\'s state', reviewExperienceB.ok && experienceReviewAAfterB.ok && experienceReviewAAfterB.data.length === 1 && experienceReviewAAfterB.data[0].user_id === userA.id); + + const deleteExperienceA = await rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { + method: 'DELETE', prefer: 'return=representation', + }); + const experienceReviewAfterDelete = await rest(tokenA, '/profile_section_reviews?section_key=eq.experience&select=reviewed_content_revision'); + const experienceRevisionAfterDelete = await rest(tokenA, '/profile_section_revisions?section_key=eq.experience&select=content_revision'); + const zeroRowExperienceDelete = await rest(tokenA, `/work_experience?id=eq.${experienceA.id}`, { + method: 'DELETE', prefer: 'return=representation', + }); + check('owner can delete experience and the prior review remains stale', deleteExperienceA.ok && deleteExperienceA.data.length === 1 && experienceReviewAfterDelete.data[0].reviewed_content_revision !== experienceRevisionAfterDelete.data[0].content_revision); + check('a deleted experience returns zero rows on a later delete', zeroRowExperienceDelete.ok && zeroRowExperienceDelete.data.length === 0); + // ---- education CRUD, primary RPC, and review freshness ----------------- const createEducationA = await rest(tokenA, '/education_entries', { method: 'POST', @@ -230,14 +349,14 @@ async function main() { check('user A can select their current education through the primary RPC', selectPrimary.ok); const revisionsBeforeReview = await rest(tokenA, '/profile_section_revisions?select=section_key,content_revision'); - check('user A can read their own section revisions', revisionsBeforeReview.ok && revisionsBeforeReview.data.length === 2); + check('user A can read their own section revisions', revisionsBeforeReview.ok && revisionsBeforeReview.data.length === 3); const reviewEducation = await rest(tokenA, '/rpc/review_profile_section', { method: 'POST', body: { requested_section_key: 'education' }, }); const reviewBasic = await rest(tokenA, '/rpc/review_profile_section', { method: 'POST', body: { requested_section_key: 'basic_profile' }, }); - check('user A can review both implemented sections through constrained RPCs', reviewEducation.ok && reviewBasic.ok); + check('user A can review the remaining implemented profile and education sections through constrained RPCs', reviewEducation.ok && reviewBasic.ok); const mutateProfileAfterReview = await rest(tokenA, `/profiles?user_id=eq.${userA.id}`, { method: 'PATCH', body: { headline: 'Changed after review' }, prefer: 'return=representation', From 9b4e8e637a9dbf2c655695147e128bf22a9c4615 Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Mon, 3 Aug 2026 11:16:27 +0300 Subject: [PATCH 3/3] feat: add experience profile workflow Add protected bookmarkable experience routes, a shared accessible editor, review-aware list and delete flows, and experience-aware partial completeness. Preserve explicit repository reloads and keep deferred profile domains neutral. --- app/src/App.test.tsx | 5 + app/src/App.tsx | 8 + app/src/AppRouter.test.tsx | 8 +- app/src/index.css | 16 +- app/src/lib/profileCompleteness.test.ts | 38 ++- app/src/lib/profileCompleteness.ts | 53 +++- app/src/lib/profileReviewRepository.ts | 8 +- app/src/lib/profileValidation.test.ts | 81 ++++++ app/src/lib/profileValidation.ts | 93 +++++++ app/src/pages/ExperienceEditorPage.test.tsx | 178 +++++++++++++ app/src/pages/ExperienceEditorPage.tsx | 280 ++++++++++++++++++++ app/src/pages/ExperienceListPage.test.tsx | 173 ++++++++++++ app/src/pages/ExperienceListPage.tsx | 196 ++++++++++++++ app/src/pages/ProfileLayout.tsx | 1 + app/src/pages/ProfilePage.test.tsx | 16 ++ app/src/pages/ProfilePage.tsx | 44 ++- docs/ARCHITECTURE.md | 5 + docs/DEVELOPMENT_ROADMAP.md | 5 +- docs/PROFILE_COMPLETENESS_SPEC.md | 13 +- docs/USER_WORKFLOWS.md | 10 +- 20 files changed, 1187 insertions(+), 44 deletions(-) create mode 100644 app/src/lib/profileValidation.test.ts create mode 100644 app/src/pages/ExperienceEditorPage.test.tsx create mode 100644 app/src/pages/ExperienceEditorPage.tsx create mode 100644 app/src/pages/ExperienceListPage.test.tsx create mode 100644 app/src/pages/ExperienceListPage.tsx diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 61d0dee..f7487f5 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -34,6 +34,11 @@ vi.mock('./lib/educationRepository', () => ({ list: () => Promise.resolve({ data: [], error: null }), }, })) +vi.mock('./lib/experienceRepository', () => ({ + experienceRepository: { + list: () => Promise.resolve({ data: [], error: null }), + }, +})) vi.mock('./lib/profileReviewRepository', () => ({ profileReviewRepository: { state: () => Promise.resolve({ data: [], error: null }), diff --git a/app/src/App.tsx b/app/src/App.tsx index 6603508..f203aca 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,8 @@ import { ProfileLayout } from './pages/ProfileLayout' import { BasicProfilePage } from './pages/BasicProfilePage' import { EducationEditorPage } from './pages/EducationEditorPage' import { EducationListPage } from './pages/EducationListPage' +import { ExperienceEditorPage } from './pages/ExperienceEditorPage' +import { ExperienceListPage } from './pages/ExperienceListPage' import { ProfilePage } from './pages/ProfilePage' import { SignInPage } from './pages/SignInPage' @@ -97,6 +99,12 @@ const router = createBrowserRouter([ path: 'education/:educationId/edit', element: , }, + { path: 'experience', element: }, + { path: 'experience/new', element: }, + { + path: 'experience/:experienceId/edit', + element: , + }, ], }, ], diff --git a/app/src/AppRouter.test.tsx b/app/src/AppRouter.test.tsx index 9c422ac..71f3447 100644 --- a/app/src/AppRouter.test.tsx +++ b/app/src/AppRouter.test.tsx @@ -4,14 +4,14 @@ import { RouterProvider } from 'react-router/dom' import { describe, expect, it } from 'vitest' describe('router foundation', () => { - it('uses an in-memory router to honor a bookmarkable route', async () => { + it('uses an in-memory router to honor a bookmarkable nested route', async () => { const memoryRouter = createMemoryRouter( - [{ path: '/profile/education', element:

Education

}], - { initialEntries: ['/profile/education'] }, + [{ path: '/profile/experience/new', element:

Add experience

}], + { initialEntries: ['/profile/experience/new'] }, ) render() expect( - await screen.findByRole('heading', { name: 'Education' }), + await screen.findByRole('heading', { name: 'Add experience' }), ).toBeInTheDocument() }) }) diff --git a/app/src/index.css b/app/src/index.css index 8fd759b..848a0d3 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -56,7 +56,9 @@ label { font-size: 0.9rem; } -input { +input, +select, +textarea { font: inherit; padding: 0.5rem 0.6rem; border: 1px solid var(--border); @@ -65,6 +67,18 @@ input { color: var(--text); } +textarea { + min-height: 7rem; + resize: vertical; +} + +nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0 0 1.5rem; +} + button { font: inherit; padding: 0.5rem 0.9rem; diff --git a/app/src/lib/profileCompleteness.test.ts b/app/src/lib/profileCompleteness.test.ts index fa16342..9782d90 100644 --- a/app/src/lib/profileCompleteness.test.ts +++ b/app/src/lib/profileCompleteness.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { completenessVersion, - evaluateEducationSlice, + evaluateExperienceSlice, } from './profileCompleteness' -describe('education completeness slice', () => { +describe('experience completeness slice', () => { it('is versioned and exposes named missing actions without a percentage', () => { - const checks = evaluateEducationSlice( + const checks = evaluateExperienceSlice( [], [ { @@ -20,8 +20,9 @@ describe('education completeness slice', () => { reviewed_content_revision: null, }, ], + [], ) - expect(completenessVersion).toBe('profile-completeness/v2-slice-education') + expect(completenessVersion).toBe('profile-completeness/v2-slice-experience') expect( checks.find((check) => check.id === 'primary_education'), ).toMatchObject({ @@ -35,12 +36,12 @@ describe('education completeness slice', () => { it('never treats missing, null, or stale review state as current', () => { expect( - evaluateEducationSlice([], []).find( + evaluateExperienceSlice([], [], []).find( (check) => check.id === 'basic_profile_review', )?.outcome, ).toBe('unconfirmed') expect( - evaluateEducationSlice( + evaluateExperienceSlice( [], [ { @@ -49,10 +50,11 @@ describe('education completeness slice', () => { reviewed_content_revision: null, }, ], + [], ).find((check) => check.id === 'basic_profile_review')?.outcome, ).toBe('unconfirmed') expect( - evaluateEducationSlice( + evaluateExperienceSlice( [], [ { @@ -61,7 +63,29 @@ describe('education completeness slice', () => { reviewed_content_revision: 1, }, ], + [], ).find((check) => check.id === 'basic_profile_review')?.outcome, ).toBe('unconfirmed') }) + + it('reports an empty reviewed experience section as present and deferred work as neutral', () => { + const checks = evaluateExperienceSlice( + [], + [ + { + section_key: 'experience', + content_revision: 0, + reviewed_content_revision: 0, + }, + ], + [], + ) + expect( + checks.find((check) => check.id === 'experience_review'), + ).toMatchObject({ availability: 'implemented', outcome: 'present' }) + expect(checks.find((check) => check.id === 'projects')).toMatchObject({ + availability: 'not_implemented', + outcome: null, + }) + }) }) diff --git a/app/src/lib/profileCompleteness.ts b/app/src/lib/profileCompleteness.ts index da6895e..8535664 100644 --- a/app/src/lib/profileCompleteness.ts +++ b/app/src/lib/profileCompleteness.ts @@ -1,7 +1,11 @@ -import type { EducationEntry, SectionState } from './profileTypes' +import type { + EducationEntry, + SectionState, + WorkExperience, +} from './profileTypes' export const completenessVersion = - 'profile-completeness/v2-slice-education' as const + 'profile-completeness/v2-slice-experience' as const export type CompletenessOutcome = 'present' | 'missing' | 'unconfirmed' export type CompletenessCheckId = | 'basic_profile_review' @@ -9,12 +13,20 @@ export type CompletenessCheckId = | 'degree_year' | 'graduation_timing' | 'education_review' + | 'experience_review' + | 'projects' + | 'skills' + | 'languages' + | 'preferences' + | 'eligibility' + | 'targets' export interface CompletenessCheck { id: CompletenessCheckId - outcome: CompletenessOutcome + availability: 'implemented' | 'not_implemented' + outcome: CompletenessOutcome | null action: string - href: string + href: string | null } function reviewed(state: SectionState[], section: SectionState['section_key']) { @@ -26,32 +38,41 @@ function reviewed(state: SectionState[], section: SectionState['section_key']) { ) } -export function evaluateEducationSlice( +export function evaluateExperienceSlice( entries: EducationEntry[], state: SectionState[], + experience: WorkExperience[], ): CompletenessCheck[] { + // Projects are deferred, so experience rows do not yet drive the final + // experience-or-project evidence-strength check. Keep the evaluator input + // explicit for the next slice rather than duplicating overview logic. + void experience const primary = entries.find((entry) => entry.is_primary) return [ { id: 'basic_profile_review', + availability: 'implemented', outcome: reviewed(state, 'basic_profile') ? 'present' : 'unconfirmed', action: 'Review basic profile', href: '/profile/basic', }, { id: 'primary_education', + availability: 'implemented', outcome: primary ? 'present' : 'missing', action: 'Add or select primary education', href: '/profile/education', }, { id: 'degree_year', + availability: 'implemented', outcome: primary?.degree_year ? 'present' : 'missing', action: 'Add degree year', href: '/profile/education', }, { id: 'graduation_timing', + availability: 'implemented', outcome: primary?.expected_graduation_month && primary?.expected_graduation_year ? 'present' @@ -61,9 +82,31 @@ export function evaluateEducationSlice( }, { id: 'education_review', + availability: 'implemented', outcome: reviewed(state, 'education') ? 'present' : 'unconfirmed', action: 'Review education', href: '/profile/education', }, + { + id: 'experience_review', + availability: 'implemented', + outcome: reviewed(state, 'experience') ? 'present' : 'unconfirmed', + action: 'Review experience', + href: '/profile/experience', + }, + ...[ + ['projects', 'Projects are not yet available.'], + ['skills', 'Skills are not yet available.'], + ['languages', 'Languages are not yet available.'], + ['preferences', 'Preferences are not yet available.'], + ['eligibility', 'Work eligibility is not yet available.'], + ['targets', 'Targets are not yet available.'], + ].map(([id, action]) => ({ + id: id as CompletenessCheckId, + availability: 'not_implemented' as const, + outcome: null, + action, + href: null, + })), ] } diff --git a/app/src/lib/profileReviewRepository.ts b/app/src/lib/profileReviewRepository.ts index a0a0cc4..01d21ea 100644 --- a/app/src/lib/profileReviewRepository.ts +++ b/app/src/lib/profileReviewRepository.ts @@ -1,9 +1,9 @@ import { supabase } from './supabaseClient' -import type { SectionState } from './profileTypes' +import type { SectionKey, SectionState } from './profileTypes' export function reviewStatus( state: SectionState[] | null, - section: SectionState['section_key'], + section: SectionKey, ): 'current' | 'stale' | 'not_reviewed' { const item = state?.find((entry) => entry.section_key === section) if (!item || item.reviewed_content_revision === null) return 'not_reviewed' @@ -28,7 +28,7 @@ export const profileReviewRepository = { if (reviews.error) return { data: null, error: reviews.error } return { data: (revisions.data ?? []).map((revision) => ({ - section_key: revision.section_key as SectionState['section_key'], + section_key: revision.section_key as SectionKey, content_revision: revision.content_revision, reviewed_content_revision: reviews.data?.find( @@ -38,7 +38,7 @@ export const profileReviewRepository = { error: null, } }, - async review(sectionKey: SectionState['section_key']) { + async review(sectionKey: SectionKey) { return supabase.rpc('review_profile_section', { requested_section_key: sectionKey, }) diff --git a/app/src/lib/profileValidation.test.ts b/app/src/lib/profileValidation.test.ts new file mode 100644 index 0000000..5b62bb6 --- /dev/null +++ b/app/src/lib/profileValidation.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { normalizeExperience, validateExperience } from './profileValidation' + +const valid = { + experience_kind: 'internship', + organization: 'Example Organization', + role: 'Engineering Intern', + location: '', + start_year: '2025', + start_month: '', + end_year: '', + end_month: '', + is_current: 'false', + description: '', +} + +describe('experience validation', () => { + it('normalizes optional plain-text fields without inventing month precision', () => { + expect( + normalizeExperience({ + ...valid, + organization: ' Example Organization ', + role: ' Engineering Intern ', + }), + ).toMatchObject({ + organization: 'Example Organization', + role: 'Engineering Intern', + start_month: null, + end_month: null, + }) + }) + + it('rejects invalid kinds, text, years, and months', () => { + expect( + validateExperience({ + ...valid, + experience_kind: 'unknown', + organization: ' ', + role: ' ', + start_year: '1800', + start_month: '13', + end_year: '2200', + }), + ).toMatchObject({ + experience_kind: expect.any(String), + organization: expect.any(String), + role: expect.any(String), + start_year: expect.any(String), + start_month: expect.any(String), + end_year: expect.any(String), + }) + }) + + it('rejects impossible current and ordered end periods', () => { + expect( + validateExperience({ + ...valid, + start_month: '7', + end_year: '2025', + end_month: '6', + }).end_period, + ).toMatch(/End month cannot precede/i) + expect( + validateExperience({ + ...valid, + is_current: 'true', + end_year: '2026', + }).end_period, + ).toMatch(/Current experience cannot have an end period/i) + }) + + it('accepts honest same-year partial months and an unknown non-current end', () => { + expect( + validateExperience({ + ...valid, + end_year: '2025', + end_month: '1', + }), + ).toEqual({}) + }) +}) diff --git a/app/src/lib/profileValidation.ts b/app/src/lib/profileValidation.ts index d6b26f9..5c77f00 100644 --- a/app/src/lib/profileValidation.ts +++ b/app/src/lib/profileValidation.ts @@ -1,4 +1,5 @@ import type { EducationInput } from './educationRepository' +import type { ExperienceInput } from './experienceRepository' export type FieldErrors = Record const trimmed = (value: string) => value.trim() @@ -99,3 +100,95 @@ export function validateEducation(values: Record): FieldErrors { errors.graduation = 'Expected graduation cannot precede the start month.' return errors } + +const experienceKinds: ExperienceInput['experience_kind'][] = [ + 'employment', + 'internship', + 'research', + 'volunteering', + 'student_leadership', + 'other', +] + +export function normalizeExperience( + values: Record, +): ExperienceInput { + const number = (value: string) => (value === '' ? null : Number(value)) + return { + experience_kind: + values.experience_kind as ExperienceInput['experience_kind'], + organization: trimmed(values.organization), + role: trimmed(values.role), + location: optional(values.location), + start_year: Number(values.start_year), + start_month: number(values.start_month), + end_year: number(values.end_year), + end_month: number(values.end_month), + is_current: values.is_current === 'true', + description: optional(values.description), + } +} + +export function validateExperience( + values: Record, +): FieldErrors { + const errors: FieldErrors = {} + if ( + !experienceKinds.includes( + values.experience_kind as ExperienceInput['experience_kind'], + ) + ) + errors.experience_kind = 'Choose an experience kind.' + if (!trimmed(values.organization)) + errors.organization = 'Organization is required.' + if (trimmed(values.organization).length > 200) + errors.organization = 'Organization must be 200 characters or fewer.' + if (!trimmed(values.role)) errors.role = 'Role is required.' + if (trimmed(values.role).length > 160) + errors.role = 'Role must be 160 characters or fewer.' + if (trimmed(values.location).length > 160) + errors.location = 'Location must be 160 characters or fewer.' + if (trimmed(values.description).length > 2000) + errors.description = 'Description must be 2,000 characters or fewer.' + + const startYear = Number(values.start_year) + if ( + !values.start_year || + !Number.isInteger(startYear) || + startYear < 1900 || + startYear > 2100 + ) + errors.start_year = 'Start year must be between 1900 and 2100.' + + const monthIsValid = (value: string) => + Number.isInteger(Number(value)) && Number(value) >= 1 && Number(value) <= 12 + if (values.start_month && !monthIsValid(values.start_month)) + errors.start_month = 'Start month must be between 1 and 12.' + if (values.end_month && !monthIsValid(values.end_month)) + errors.end_month = 'End month must be between 1 and 12.' + + const endYear = values.end_year ? Number(values.end_year) : null + if ( + endYear !== null && + (!Number.isInteger(endYear) || endYear < 1900 || endYear > 2100) + ) + errors.end_year = 'End year must be between 1900 and 2100.' + if (values.end_month && !values.end_year) + errors.end_month = 'Enter an end year before adding an end month.' + + if (values.is_current === 'true' && (values.end_year || values.end_month)) + errors.end_period = 'Current experience cannot have an end period.' + if (endYear !== null && Number.isInteger(startYear)) { + if (endYear < startYear) + errors.end_period = 'End year cannot precede start year.' + if ( + endYear === startYear && + values.start_month && + values.end_month && + Number(values.end_month) < Number(values.start_month) + ) + errors.end_period = + 'End month cannot precede start month in the same year.' + } + return errors +} diff --git a/app/src/pages/ExperienceEditorPage.test.tsx b/app/src/pages/ExperienceEditorPage.test.tsx new file mode 100644 index 0000000..fd1613c --- /dev/null +++ b/app/src/pages/ExperienceEditorPage.test.tsx @@ -0,0 +1,178 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ExperienceEditorPage } from './ExperienceEditorPage' + +const get = vi.fn() +const update = vi.fn() +const create = vi.fn() + +vi.mock('../contexts/AuthContext', () => ({ + useAuth: () => ({ session: { user: { id: 'user-1' } } }), +})) +vi.mock('../lib/experienceRepository', () => ({ + experienceRepository: { + get: (...args: unknown[]) => get(...args), + update: (...args: unknown[]) => update(...args), + create: (...args: unknown[]) => create(...args), + }, +})) + +const entry = { + id: 'experience-1', + user_id: 'user-1', + experience_kind: 'research' as const, + organization: 'Example Lab', + role: 'Research Assistant', + location: null, + start_year: 2025, + start_month: null, + end_year: null, + end_month: null, + is_current: true, + description: 'Synthetic description', +} + +function renderEdit() { + return render( + + + } + /> + Experience list

} /> +
+
, + ) +} + +function renderCreate() { + return render( + + + } + /> + Experience list

} /> +
+
, + ) +} + +afterEach(() => vi.resetAllMocks()) + +describe('ExperienceEditorPage', () => { + it('shows loading rather than a submit-capable form while editing loads', () => { + get.mockReturnValue(new Promise(() => {})) + renderEdit() + expect(screen.getByText('Loading…')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /save experience/i }), + ).not.toBeInTheDocument() + }) + + it('renders a bookmarkable create form with accessible labels', () => { + renderCreate() + expect( + screen.getByRole('heading', { name: 'Add experience' }), + ).toBeInTheDocument() + expect(screen.getByLabelText('Organization')).toBeInTheDocument() + expect(screen.getByLabelText('Start month (optional)')).toBeInTheDocument() + }) + + it('populates an existing entry and keeps its current state', async () => { + get.mockResolvedValue({ data: entry, error: null }) + renderEdit() + expect(await screen.findByDisplayValue('Example Lab')).toBeInTheDocument() + expect(screen.getByLabelText(/This experience is current/)).toBeChecked() + expect(screen.getByLabelText('End year (optional)')).toBeDisabled() + }) + + it('does not render a submit-capable form for a missing entry', async () => { + get.mockResolvedValue({ data: null, error: null }) + renderEdit() + expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /save experience/i }), + ).not.toBeInTheDocument() + }) + + it('offers retry after a safe load failure', async () => { + get.mockResolvedValue({ data: null, error: { code: '', message: '' } }) + renderEdit() + expect(await screen.findByRole('alert')).toHaveTextContent(/try again/i) + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + it('shows an accessible validation summary for required and period fields', async () => { + const user = userEvent.setup() + renderCreate() + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect((await screen.findAllByRole('alert'))[0]).toHaveTextContent( + /Organization is required/i, + ) + expect(screen.getByLabelText('Organization')).toHaveAttribute( + 'aria-describedby', + 'organization-error', + ) + expect(screen.getByRole('link', { name: /start year/i })).toHaveAttribute( + 'href', + '#start_year', + ) + }) + + it('clears and disables end fields when current is selected', async () => { + const user = userEvent.setup() + renderCreate() + await user.type(screen.getByLabelText('End year (optional)'), '2026') + await user.type(screen.getByLabelText('End month (optional)'), '2') + await user.click(screen.getByLabelText(/This experience is current/)) + expect(screen.getByLabelText('End year (optional)')).toHaveValue(null) + expect(screen.getByLabelText('End year (optional)')).toBeDisabled() + expect(screen.getByLabelText('End month (optional)')).toBeDisabled() + }) + + it('keeps values after a recoverable save failure', async () => { + get.mockResolvedValue({ data: entry, error: null }) + update.mockResolvedValue({ data: null, error: { code: '', message: '' } }) + const user = userEvent.setup() + renderEdit() + const organization = await screen.findByLabelText('Organization') + await user.clear(organization) + await user.type(organization, 'Changed Lab') + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect(await screen.findByRole('alert')).toHaveTextContent(/try again/i) + expect(screen.getByDisplayValue('Changed Lab')).toBeInTheDocument() + }) + + it('treats a zero-row update as a missing entry', async () => { + get.mockResolvedValue({ data: entry, error: null }) + update.mockResolvedValue({ data: null, error: null }) + const user = userEvent.setup() + renderEdit() + await screen.findByDisplayValue('Example Lab') + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument() + }) + + it('creates an entry using normalized form values', async () => { + create.mockResolvedValue({ data: entry, error: null }) + const user = userEvent.setup() + renderCreate() + await user.type(screen.getByLabelText('Organization'), ' Example Lab ') + await user.type(screen.getByLabelText('Role'), ' Research Assistant ') + await user.type(screen.getByLabelText('Start year'), '2025') + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect(create).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + organization: 'Example Lab', + role: 'Research Assistant', + }), + ) + expect(await screen.findByText('Experience list')).toBeInTheDocument() + }) +}) diff --git a/app/src/pages/ExperienceEditorPage.tsx b/app/src/pages/ExperienceEditorPage.tsx new file mode 100644 index 0000000..5a9fd12 --- /dev/null +++ b/app/src/pages/ExperienceEditorPage.tsx @@ -0,0 +1,280 @@ +import { Link, useNavigate, useParams } from 'react-router' +import { useCallback, useEffect, useState, type FormEvent } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { experienceRepository } from '../lib/experienceRepository' +import { + normalizeExperience, + validateExperience, +} from '../lib/profileValidation' +import { errorMessage, safeError } from '../lib/profileTypes' + +const empty = { + experience_kind: 'employment', + organization: '', + role: '', + location: '', + start_year: '', + start_month: '', + end_year: '', + end_month: '', + is_current: 'false', + description: '', +} + +const kinds = [ + ['employment', 'Employment'], + ['internship', 'Internship'], + ['research', 'Research'], + ['volunteering', 'Volunteering'], + ['student_leadership', 'Student organization or leadership'], + ['other', 'Other'], +] + +export function ExperienceEditorPage() { + const { experienceId } = useParams() + const navigate = useNavigate() + const { session } = useAuth() + const [values, setValues] = useState>(empty) + const [errors, setErrors] = useState>({}) + const [message, setMessage] = useState(null) + const [saving, setSaving] = useState(false) + const [loadState, setLoadState] = useState< + 'loading' | 'loaded' | 'missing' | 'error' + >(experienceId ? 'loading' : 'loaded') + + const load = useCallback(async () => { + if (!experienceId) return + const result = await experienceRepository.get(experienceId) + if (result.error) { + const kind = safeError(result.error) + setLoadState(kind === 'missing' ? 'missing' : 'error') + setMessage( + kind === 'missing' + ? 'This experience entry no longer exists.' + : errorMessage(kind), + ) + return + } + if (!result.data) { + setLoadState('missing') + setMessage('This experience entry no longer exists.') + return + } + const entry = result.data + setValues({ + experience_kind: entry.experience_kind, + organization: entry.organization, + role: entry.role, + location: entry.location ?? '', + start_year: entry.start_year.toString(), + start_month: entry.start_month?.toString() ?? '', + end_year: entry.end_year?.toString() ?? '', + end_month: entry.end_month?.toString() ?? '', + is_current: entry.is_current ? 'true' : 'false', + description: entry.description ?? '', + }) + setLoadState('loaded') + }, [experienceId]) + + useEffect(() => { + void load() + }, [load]) + + function field(name: string, value: string) { + setValues((current) => ({ ...current, [name]: value })) + } + + function setCurrent(isCurrent: boolean) { + setValues((current) => ({ + ...current, + is_current: isCurrent ? 'true' : 'false', + end_year: isCurrent ? '' : current.end_year, + end_month: isCurrent ? '' : current.end_month, + })) + } + + async function save(event: FormEvent) { + event.preventDefault() + const next = validateExperience(values) + setErrors(next) + if (Object.keys(next).length || loadState !== 'loaded' || saving) return + setSaving(true) + const input = normalizeExperience(values) + const result = experienceId + ? await experienceRepository.update(experienceId, input) + : await experienceRepository.create(session!.user.id, input) + if (result.error) { + setMessage(errorMessage(safeError(result.error))) + setSaving(false) + return + } + if (!result.data) { + setMessage( + 'This experience entry no longer exists. Return to experience.', + ) + setLoadState('missing') + setSaving(false) + return + } + navigate('/profile/experience', { replace: true }) + } + + const input = ( + name: string, + label: string, + type = 'text', + disabled = false, + ) => ( +
+ + field(name, event.target.value)} + aria-invalid={Boolean(errors[name])} + aria-describedby={errors[name] ? `${name}-error` : undefined} + /> + {errors[name] && ( + + )} +
+ ) + + if (loadState === 'loading') + return ( +
+

Edit experience

+

Loading…

+
+ ) + if (loadState === 'missing' || loadState === 'error') + return ( +
+

Edit experience

+

{message}

+

+ Return to experience +

+ {loadState === 'error' && ( + + )} +
+ ) + + const current = values.is_current === 'true' + const summaryFields = Object.keys(errors).map((name) => + name === 'end_period' ? 'experience-period' : name, + ) + return ( +
+

{experienceId ? 'Edit experience' : 'Add experience'}

+
+ {summaryFields.length > 0 && ( +
+

Please correct the fields below.

+ +
+ )} +
+ + + {errors.experience_kind && ( + + )} +
+ {input('organization', 'Organization')} + {input('role', 'Role')} + {input('location', 'Location')} +
+ Experience period + {input('start_year', 'Start year', 'number')} + {input('start_month', 'Start month (optional)', 'number')} +
+ +
+ {input('end_year', 'End year (optional)', 'number', current)} +
+ + field('end_month', event.target.value)} + aria-invalid={Boolean(errors.end_month)} + aria-describedby={ + errors.end_month ? 'end_month-error' : undefined + } + /> + {errors.end_month && ( + + )} +
+ {errors.end_period &&

{errors.end_period}

} +
+
+ +