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,
+ ) => (
+
+
{label}
+
field(name, event.target.value)}
+ aria-invalid={Boolean(errors[name])}
+ aria-describedby={errors[name] ? `${name}-error` : undefined}
+ />
+ {errors[name] && (
+
+ {errors[name]}
+
+ )}
+
+ )
+
+ if (loadState === 'loading')
+ return (
+
+ Edit experience
+ Loading…
+
+ )
+ if (loadState === 'missing' || loadState === 'error')
+ return (
+
+ Edit experience
+ {message}
+
+ Return to experience
+
+ {loadState === 'error' && (
+ void load()}>
+ Retry
+
+ )}
+
+ )
+
+ 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'}
+
+ {message && {message}
}
+
+ Cancel
+
+
+ )
+}
diff --git a/app/src/pages/ExperienceListPage.test.tsx b/app/src/pages/ExperienceListPage.test.tsx
new file mode 100644
index 0000000..b9ed35d
--- /dev/null
+++ b/app/src/pages/ExperienceListPage.test.tsx
@@ -0,0 +1,173 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ExperienceListPage } from './ExperienceListPage'
+
+const list = vi.fn()
+const remove = vi.fn()
+const sectionState = vi.fn()
+const review = vi.fn()
+
+vi.mock('../contexts/AuthContext', () => ({
+ useAuth: () => ({ session: { user: { id: 'user-1' } } }),
+}))
+vi.mock('../lib/experienceRepository', () => ({
+ experienceRepository: {
+ list: (...args: unknown[]) => list(...args),
+ remove: (...args: unknown[]) => remove(...args),
+ },
+}))
+vi.mock('../lib/profileReviewRepository', () => ({
+ profileReviewRepository: {
+ state: (...args: unknown[]) => sectionState(...args),
+ review: (...args: unknown[]) => review(...args),
+ },
+ reviewStatus: (
+ state: Array<{
+ reviewed_content_revision: number | null
+ content_revision: number
+ }>,
+ ) =>
+ state[0]?.reviewed_content_revision === null
+ ? 'not_reviewed'
+ : state[0]?.reviewed_content_revision === state[0]?.content_revision
+ ? 'current'
+ : 'stale',
+}))
+
+const entry = {
+ id: 'experience-1',
+ user_id: 'user-1',
+ experience_kind: 'research' as const,
+ organization: 'Example Lab',
+ role: 'Research Assistant',
+ location: 'Bremen',
+ start_year: 2025,
+ start_month: 10,
+ end_year: null,
+ end_month: null,
+ is_current: true,
+ description: null,
+}
+const notReviewed = {
+ data: [
+ {
+ section_key: 'experience',
+ content_revision: 1,
+ reviewed_content_revision: null,
+ },
+ ],
+ error: null,
+}
+const current = {
+ data: [
+ {
+ section_key: 'experience',
+ content_revision: 1,
+ reviewed_content_revision: 1,
+ },
+ ],
+ error: null,
+}
+
+function renderPage() {
+ return render(
+
+
+ ,
+ )
+}
+
+afterEach(() => vi.resetAllMocks())
+
+describe('ExperienceListPage', () => {
+ it('shows loading before the repository resolves', () => {
+ list.mockReturnValue(new Promise(() => {}))
+ renderPage()
+ expect(screen.getByText('Loading…')).toBeInTheDocument()
+ })
+
+ it('shows an empty experience section and its review state', async () => {
+ list.mockResolvedValue({ data: [], error: null })
+ sectionState.mockResolvedValue(notReviewed)
+ renderPage()
+ expect(
+ await screen.findByText(/No experience entries yet/),
+ ).toBeInTheDocument()
+ expect(screen.getByText(/Not reviewed/)).toBeInTheDocument()
+ })
+
+ it('renders readable kind and an honest current period', async () => {
+ list.mockResolvedValue({ data: [entry], error: null })
+ sectionState.mockResolvedValue(current)
+ renderPage()
+ expect(await screen.findByText('Research')).toBeInTheDocument()
+ expect(screen.getByText('Oct 2025 — Present')).toBeInTheDocument()
+ expect(screen.getByText(/Reviewed and current/)).toBeInTheDocument()
+ })
+
+ it('offers retry after a loading failure', async () => {
+ list.mockResolvedValue({ data: null, error: { code: '', message: '' } })
+ renderPage()
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ /could not be loaded/i,
+ )
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
+ })
+
+ it('refreshes review state after a successful review', async () => {
+ list.mockResolvedValue({ data: [], error: null })
+ sectionState
+ .mockResolvedValueOnce(notReviewed)
+ .mockResolvedValueOnce(current)
+ review.mockResolvedValue({ data: null, error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByText(/Not reviewed/)
+ await user.click(
+ screen.getByRole('button', { name: /mark experience reviewed/i }),
+ )
+ expect(await screen.findByText(/Reviewed and current/)).toBeInTheDocument()
+ })
+
+ it('confirms deletion, reloads, and displays a stale review', async () => {
+ list
+ .mockResolvedValueOnce({ data: [entry], error: null })
+ .mockResolvedValueOnce({ data: [], error: null })
+ sectionState.mockResolvedValueOnce(current).mockResolvedValueOnce({
+ data: [
+ {
+ section_key: 'experience',
+ content_revision: 2,
+ reviewed_content_revision: 1,
+ },
+ ],
+ error: null,
+ })
+ remove.mockResolvedValue({ data: [{ id: entry.id }], error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByText('Research Assistant')
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Confirm delete' }))
+ expect(
+ await screen.findByText(/No experience entries yet/),
+ ).toBeInTheDocument()
+ expect(screen.getByText(/Stale/)).toBeInTheDocument()
+ })
+
+ it('does not claim success after a zero-row delete', async () => {
+ list.mockResolvedValue({ data: [entry], error: null })
+ sectionState.mockResolvedValue(current)
+ remove.mockResolvedValue({ data: [], error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByText('Research Assistant')
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+ await user.click(screen.getByRole('button', { name: 'Confirm delete' }))
+ expect(await screen.findByText(/may already be gone/i)).toBeInTheDocument()
+ expect(screen.queryByText('Experience deleted.')).not.toBeInTheDocument()
+ })
+})
diff --git a/app/src/pages/ExperienceListPage.tsx b/app/src/pages/ExperienceListPage.tsx
new file mode 100644
index 0000000..758fe0f
--- /dev/null
+++ b/app/src/pages/ExperienceListPage.tsx
@@ -0,0 +1,196 @@
+import { Link } from 'react-router'
+import { useCallback, useEffect, useState } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { experienceRepository } from '../lib/experienceRepository'
+import {
+ profileReviewRepository,
+ reviewStatus,
+} from '../lib/profileReviewRepository'
+import {
+ errorMessage,
+ safeError,
+ type WorkExperience,
+} from '../lib/profileTypes'
+
+const kindLabels: Record = {
+ employment: 'Employment',
+ internship: 'Internship',
+ research: 'Research',
+ volunteering: 'Volunteering',
+ student_leadership: 'Student organization or leadership',
+ other: 'Other',
+}
+
+function periodPart(year: number, month: number | null) {
+ if (!month) return String(year)
+ return new Intl.DateTimeFormat('en', {
+ month: 'short',
+ year: 'numeric',
+ }).format(new Date(Date.UTC(year, month - 1, 1)))
+}
+
+function experiencePeriod(entry: WorkExperience) {
+ const start = periodPart(entry.start_year, entry.start_month)
+ if (entry.is_current) return `${start} — Present`
+ if (!entry.end_year) return `${start} — End date not recorded`
+ return `${start} — ${periodPart(entry.end_year, entry.end_month)}`
+}
+
+export function ExperienceListPage() {
+ const { session } = useAuth()
+ const userId = session?.user.id
+ const [entries, setEntries] = useState([])
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
+ const [deleting, setDeleting] = useState(null)
+ const [pendingOperation, setPendingOperation] = useState(null)
+ const [reviewing, setReviewing] = useState(false)
+ const [message, setMessage] = useState(null)
+ const [sectionState, setSectionState] = useState<
+ 'current' | 'stale' | 'not_reviewed'
+ >('not_reviewed')
+
+ const load = useCallback(async () => {
+ if (!userId) return
+ setStatus('loading')
+ const result = await experienceRepository.list(userId)
+ if (result.error) return setStatus('error')
+ const sections = await profileReviewRepository.state(userId)
+ if (sections.error) return setStatus('error')
+ setEntries(result.data)
+ setSectionState(reviewStatus(sections.data, 'experience'))
+ setStatus('ready')
+ }, [userId])
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves
+ void load()
+ }, [load])
+
+ async function remove(id: string) {
+ if (pendingOperation) return
+ setPendingOperation(`delete:${id}`)
+ const result = await experienceRepository.remove(id)
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else if (!result.data?.length)
+ setMessage(
+ 'This experience could not be deleted. It may already be gone.',
+ )
+ else {
+ setMessage('Experience deleted.')
+ await load()
+ }
+ setDeleting(null)
+ setPendingOperation(null)
+ }
+
+ async function review() {
+ if (reviewing) return
+ setReviewing(true)
+ const result = await profileReviewRepository.review('experience')
+ setMessage(
+ result.error
+ ? errorMessage(safeError(result.error))
+ : 'Experience reviewed. Review status was refreshed.',
+ )
+ if (!result.error) await load()
+ setReviewing(false)
+ }
+
+ if (status === 'loading')
+ return (
+
+ Experience and research
+ Loading…
+
+ )
+ if (status === 'error')
+ return (
+
+ Experience and research
+ Experience could not be loaded.
+ void load()}>
+ Retry
+
+
+ )
+ return (
+
+ Experience and research
+
+ Review status:{' '}
+ {sectionState === 'current'
+ ? 'Reviewed and current'
+ : sectionState === 'stale'
+ ? 'Stale — review again'
+ : 'Not reviewed'}
+
+
+ Add experience
+
+ {entries.length === 0 ? (
+ No experience entries yet.
+ ) : (
+
+ {entries.map((entry) => (
+
+ {entry.role} at {entry.organization}
+
+ {kindLabels[entry.experience_kind]}
+
+ {experiencePeriod(entry)}
+ {entry.location && (
+ <>
+
+ {entry.location}
+ >
+ )}
+
+ Edit
+ setDeleting(entry.id)}
+ >
+ Delete
+
+ {deleting === entry.id && (
+
+
Delete this experience entry?
+
void remove(entry.id)}
+ >
+ {pendingOperation === `delete:${entry.id}`
+ ? 'Deleting…'
+ : 'Confirm delete'}
+
+
setDeleting(null)}
+ >
+ Cancel
+
+
+ )}
+
+ ))}
+
+ )}
+ void review()}
+ >
+ {reviewing ? 'Saving review…' : 'Mark experience reviewed'}
+
+ {message && {message}
}
+
+ )
+}
diff --git a/app/src/pages/ProfileLayout.tsx b/app/src/pages/ProfileLayout.tsx
index a56795f..6872673 100644
--- a/app/src/pages/ProfileLayout.tsx
+++ b/app/src/pages/ProfileLayout.tsx
@@ -31,6 +31,7 @@ export function ProfileLayout() {
Basic profile
Education
+ Experience
diff --git a/app/src/pages/ProfilePage.test.tsx b/app/src/pages/ProfilePage.test.tsx
index 2761145..3b6c9e0 100644
--- a/app/src/pages/ProfilePage.test.tsx
+++ b/app/src/pages/ProfilePage.test.tsx
@@ -5,6 +5,7 @@ import { ProfilePage } from './ProfilePage'
const getProfile = vi.fn()
const listEducation = vi.fn()
+const listExperience = vi.fn()
const sectionState = vi.fn()
vi.mock('../contexts/AuthContext', () => ({
@@ -20,6 +21,11 @@ vi.mock('../lib/profileRepository', () => ({
vi.mock('../lib/educationRepository', () => ({
educationRepository: { list: (...args: unknown[]) => listEducation(...args) },
}))
+vi.mock('../lib/experienceRepository', () => ({
+ experienceRepository: {
+ list: (...args: unknown[]) => listExperience(...args),
+ },
+}))
vi.mock('../lib/profileReviewRepository', () => ({
profileReviewRepository: {
state: (...args: unknown[]) => sectionState(...args),
@@ -30,6 +36,7 @@ describe('ProfilePage', () => {
it('shows a loading state before profile data resolves', () => {
getProfile.mockReturnValue(new Promise(() => {}))
listEducation.mockResolvedValue({ data: [], error: null })
+ listExperience.mockResolvedValue({ data: [], error: null })
sectionState.mockResolvedValue({ data: [], error: null })
render(
@@ -41,6 +48,7 @@ describe('ProfilePage', () => {
it('shows actionable partial completeness without a percentage', async () => {
getProfile.mockResolvedValue({ data: { user_id: 'user-1' }, error: null })
listEducation.mockResolvedValue({ data: [], error: null })
+ listExperience.mockResolvedValue({ data: [], error: null })
sectionState.mockResolvedValue({
data: [
{
@@ -53,6 +61,11 @@ describe('ProfilePage', () => {
content_revision: 0,
reviewed_content_revision: null,
},
+ {
+ section_key: 'experience',
+ content_revision: 0,
+ reviewed_content_revision: null,
+ },
],
error: null,
})
@@ -67,5 +80,8 @@ describe('ProfilePage', () => {
expect(
screen.getByRole('heading', { name: /not yet available/i }),
).toBeInTheDocument()
+ expect(
+ screen.getByText(/Projects are not yet available/i),
+ ).toBeInTheDocument()
})
})
diff --git a/app/src/pages/ProfilePage.tsx b/app/src/pages/ProfilePage.tsx
index 226ed32..648c990 100644
--- a/app/src/pages/ProfilePage.tsx
+++ b/app/src/pages/ProfilePage.tsx
@@ -2,10 +2,15 @@ import { Link } from 'react-router'
import { useCallback, useEffect, useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { educationRepository } from '../lib/educationRepository'
+import { experienceRepository } from '../lib/experienceRepository'
import { profileRepository } from '../lib/profileRepository'
import { profileReviewRepository } from '../lib/profileReviewRepository'
-import type { EducationEntry, SectionState } from '../lib/profileTypes'
-import { evaluateEducationSlice } from '../lib/profileCompleteness'
+import type {
+ EducationEntry,
+ SectionState,
+ WorkExperience,
+} from '../lib/profileTypes'
+import { evaluateExperienceSlice } from '../lib/profileCompleteness'
export function ProfilePage() {
const { session } = useAuth()
@@ -15,20 +20,28 @@ export function ProfilePage() {
)
const [state, setState] = useState(null)
const [entries, setEntries] = useState([])
+ const [experience, setExperience] = useState([])
const [createError, setCreateError] = useState(null)
const load = useCallback(async () => {
if (!userId) return
setStatus('loading')
- const [profile, education, sections] = await Promise.all([
+ const [profile, education, experienceResult, sections] = await Promise.all([
profileRepository.get(userId),
educationRepository.list(userId),
+ experienceRepository.list(userId),
profileReviewRepository.state(userId),
])
- if (profile.error || education.error || sections.error)
+ if (
+ profile.error ||
+ education.error ||
+ experienceResult.error ||
+ sections.error
+ )
return setStatus('error')
if (!profile.data) return setStatus('empty')
setEntries(education.data)
+ setExperience(experienceResult.data)
setState(sections.data)
setStatus('ready')
}, [userId])
@@ -79,28 +92,35 @@ export function ProfilePage() {
)
- const checks = evaluateEducationSlice(entries, state ?? [])
+ const checks = evaluateExperienceSlice(entries, state ?? [], experience)
+ const implementedChecks = checks.filter(
+ (check) => check.availability === 'implemented',
+ )
+ const deferredChecks = checks.filter(
+ (check) => check.availability === 'not_implemented',
+ )
return (
Your profile
Complete the implemented profile details below.
- {checks.map((check) => (
+ {implementedChecks.map((check) => (
{check.id.replaceAll('_', ' ')}: {' '}
{check.outcome === 'present' ? (
'Present'
- ) : (
+ ) : check.href ? (
{check.action}
- )}
+ ) : null}
))}
Not yet available
-
- Experience, skills, languages, preferences, targets, and work
- eligibility are not yet available in CareerOS.
-
+
+ {deferredChecks.map((check) => (
+ {check.action}
+ ))}
+
)
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 7b7e44e..2f19f03 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -100,6 +100,11 @@ remain enough until shared cached queries across routes, repetitive mutation inv
background refresh, optimistic updates, or more complex server-state coordination provides a
concrete need.
+**Implemented slices**: the profile workspace now includes Basic Profile, Education, and
+Experience and Research routes. The experience collection uses the same independent-save,
+explicit-reload pattern as education; its dates carry year plus optional month precision rather
+than a fabricated day. Projects/links and every remaining Phase 1A section are still deferred.
+
### Data and auth layer — Supabase (managed Postgres + Auth + Storage)
Supabase is not treated as a black box; it is specifically "managed Postgres with batteries." The
diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md
index 2608cdc..11c49d2 100644
--- a/docs/DEVELOPMENT_ROADMAP.md
+++ b/docs/DEVELOPMENT_ROADMAP.md
@@ -50,8 +50,9 @@ infer a status, or map the ambiguous degree-program text.
**Exit condition**: Workflow 1 works through manual entry only, with independent section saves,
actionable completeness, and verified cross-user isolation.
-Later Phase 1A PRs add experience/projects/links; then skills/evidence/languages; then
-preferences/targets/work eligibility; and finally the complete v2 evaluator. At every stage,
+The experience/research PR adds the `work_experience` vertical slice, including its review and
+partial-completeness state. Later Phase 1A PRs add projects/links; then skills/evidence/languages;
+then preferences/targets/work eligibility; and finally the complete v2 evaluator. At every stage,
unimplemented checks are shown as product-unavailable rather than user omissions.
## Phase 1B — Resume intake and extraction drafts
diff --git a/docs/PROFILE_COMPLETENESS_SPEC.md b/docs/PROFILE_COMPLETENESS_SPEC.md
index b3f33ea..4bd62f2 100644
--- a/docs/PROFILE_COMPLETENESS_SPEC.md
+++ b/docs/PROFILE_COMPLETENESS_SPEC.md
@@ -71,8 +71,11 @@ the section revision; “stale” means it does not.
| `all-technical-skills-evidenced` | Two technical claims, each with one valid typed evidence row. | `technical_skill` and `skill_evidence` are `present`; lacking count 0. |
| `eligibility-stale` | Work-eligibility row exists, but its confirmation or section review predates a change. | `eligibility_reviewed = unconfirmed`. |
-The implemented first vertical slice exposes `profile-completeness/v2-slice-education`. It evaluates
-only `basic_profile_review`, `primary_education`, `degree_year`, `graduation_timing`, and
-`education_review`, using `present`, `missing`, and `unconfirmed`. All remaining v2 inputs are a
-UI-only `not_implemented` availability state, never a user omission or a percentage. The full v2
-evaluation begins only after its remaining Phase 1A tables exist.
+The implemented experience slice exposes `profile-completeness/v2-slice-experience`. It evaluates
+only `basic_profile_review`, `primary_education`, `degree_year`, `graduation_timing`,
+`education_review`, and `experience_review`, using `present`, `missing`, and `unconfirmed`.
+`experience_review` is `present` when an empty or populated experience section is deliberately
+reviewed at its current revision; it does not make experience a basic-readiness requirement.
+Projects, skills, languages, preferences, eligibility, and targets remain a UI-only
+`not_implemented` availability state, never a user omission or a percentage. The final
+`experience_or_project` evidence-strength check waits for the separate projects slice.
diff --git a/docs/USER_WORKFLOWS.md b/docs/USER_WORKFLOWS.md
index a3c5715..708d3f4 100644
--- a/docs/USER_WORKFLOWS.md
+++ b/docs/USER_WORKFLOWS.md
@@ -5,10 +5,12 @@ architecture and data-model decisions grounded in real usage rather than abstrac
## Workflow 1 — Phase 1A manual profile setup
-**Implemented first slice**: the routed profile workspace currently supports basic-profile editing
-and education drafts/CRUD. A user explicitly selects one current education as primary; no other
-entry is promoted automatically. The overview shows only the implemented review/education checks
-and labels all other Phase 1A sections as not yet available.
+**Implemented slices**: the routed profile workspace supports basic-profile editing, education
+drafts/CRUD, and experience/research CRUD. Experience uses a controlled kind, honest year plus
+optional month periods, independent review, and no invented end date for current or unknown-ended
+roles. A user explicitly selects one current education as primary; no other entry is promoted
+automatically. The overview shows only implemented review checks and labels remaining Phase 1A
+sections as not yet available.
1. User signs in and sees a profile overview with named completeness checks and concrete next
actions, never just a mysterious percentage.