Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ jobs:
- name: Reset database from empty (applies every migration)
run: supabase db reset

- name: Verify Phase 0-to-Phase 1A migration compatibility
run: ./supabase/scripts/migration-compatibility-test.sh

- name: Reset database from empty for test suite
run: supabase db reset

- name: Run pgTAP database/RLS tests
run: supabase test db

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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 (foundation) is implemented: a React/TypeScript/Vite frontend, a local Supabase stack
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.

Expand Down Expand Up @@ -114,6 +115,9 @@ cd app && npm ci && npm run lint && npm run typecheck && npm run test && npm run
# Database: pgTAP RLS tests, run against the real local Postgres instance
supabase test db

# Migration compatibility: Phase 0 schema/data -> Phase 1A migration
./supabase/scripts/migration-compatibility-test.sh

# API-path integration test: real Auth -> JWT -> PostgREST -> RLS -> Postgres,
# using temporary users (each with their own randomly generated per-run
# password) that are always cleaned up afterward
Expand Down
31 changes: 29 additions & 2 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"dependencies": {
"@supabase/supabase-js": "^2.110.8",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"react-router": "8.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand Down
16 changes: 16 additions & 0 deletions app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ vi.mock('./lib/supabaseClient', () => ({
},
}))

vi.mock('./lib/profileRepository', () => ({
profileRepository: {
get: () => Promise.resolve({ data: null, error: null }),
},
}))
vi.mock('./lib/educationRepository', () => ({
educationRepository: {
list: () => Promise.resolve({ data: [], error: null }),
},
}))
vi.mock('./lib/profileReviewRepository', () => ({
profileReviewRepository: {
state: () => Promise.resolve({ data: [], error: null }),
},
}))

describe('App', () => {
beforeEach(() => {
mockGetSession.mockReset()
Expand Down
111 changes: 91 additions & 20 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,113 @@
import {
createBrowserRouter,
Navigate,
Outlet,
useRouteError,
} from 'react-router'
import { RouterProvider } from 'react-router/dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { SignInPage } from './pages/SignInPage'
import { ProfileLayout } from './pages/ProfileLayout'
import { BasicProfilePage } from './pages/BasicProfilePage'
import { EducationEditorPage } from './pages/EducationEditorPage'
import { EducationListPage } from './pages/EducationListPage'
import { ProfilePage } from './pages/ProfilePage'
import { SignInPage } from './pages/SignInPage'

// This shell decides which screen to render based on whether a session
// exists. That is a user-experience convenience only -- it runs entirely in
// the browser and does not authorize anything. The actual authorization
// boundary is Postgres Row Level Security, enforced server-side on every
// request regardless of what this component renders (see
// docs/RLS_POLICY_MATRIX.md and AGENTS.md).
function AppShell() {
const { session, loading, initError, retryInit } = useAuth()

function AuthPending() {
const { initError, loading, retryInit } = useAuth()
if (loading)
return (
<main>
<p>Loading…</p>
</main>
)
if (initError) {
return (
<main>
<p role="alert">{initError}</p>
<p role="alert">Could not check your sign-in status.</p>
<button type="button" onClick={retryInit}>
Retry
</button>
</main>
)
}
return null
}

if (loading) {
return (
<main>
<p>Loading…</p>
</main>
)
}
function RootRedirect() {
const { session, loading, initError } = useAuth()
if (loading || initError) return <AuthPending />
return <Navigate to={session ? '/profile' : '/sign-in'} replace />
}

function RequireAuth() {
const { session, loading, initError } = useAuth()
if (loading || initError) return <AuthPending />
return session ? <Outlet /> : <Navigate to="/sign-in" replace />
}

return session ? <ProfilePage /> : <SignInPage />
function SignInRoute() {
const { session, loading, initError } = useAuth()
if (loading || initError) return <AuthPending />
return session ? <Navigate to="/profile" replace /> : <SignInPage />
}

function NotFoundPage() {
return (
<main>
<h1>Page not found</h1>
<p>The page you requested is not available.</p>
</main>
)
}

function RouteErrorPage() {
useRouteError()
return (
<main>
<h1>Something went wrong</h1>
<p role="alert">
The page could not be displayed. Please return to your profile and try
again.
</p>
</main>
)
}

const router = createBrowserRouter([
{ path: '/', element: <RootRedirect />, errorElement: <RouteErrorPage /> },
{
path: '/sign-in',
element: <SignInRoute />,
errorElement: <RouteErrorPage />,
},
{
element: <RequireAuth />,
errorElement: <RouteErrorPage />,
children: [
{
path: '/profile',
element: <ProfileLayout />,
children: [
{ index: true, element: <ProfilePage /> },
{ path: 'basic', element: <BasicProfilePage /> },
{ path: 'education', element: <EducationListPage /> },
{ path: 'education/new', element: <EducationEditorPage /> },
{
path: 'education/:educationId/edit',
element: <EducationEditorPage />,
},
],
},
],
},
{ path: '*', element: <NotFoundPage /> },
])

function App() {
return (
<AuthProvider>
<AppShell />
<RouterProvider router={router} />
</AuthProvider>
)
}
Expand Down
17 changes: 17 additions & 0 deletions app/src/AppRouter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react'
import { createMemoryRouter } from 'react-router'
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 () => {
const memoryRouter = createMemoryRouter(
[{ path: '/profile/education', element: <h1>Education</h1> }],
{ initialEntries: ['/profile/education'] },
)
render(<RouterProvider router={memoryRouter} />)
expect(
await screen.findByRole('heading', { name: 'Education' }),
).toBeInTheDocument()
})
})
56 changes: 56 additions & 0 deletions app/src/lib/educationRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { supabase } from './supabaseClient'
import type { EducationEntry, EducationStatus } from './profileTypes'

export type EducationInput = {
institution: string
degree: string | null
field: string | null
degree_year: number | null
expected_graduation_month: number | null
expected_graduation_year: number | null
education_status: EducationStatus
start_date: string | null
end_date: string | null
}

const columns =
'id, user_id, institution, degree, field, degree_year, expected_graduation_month, expected_graduation_year, education_status, is_primary, start_date, end_date'

export const educationRepository = {
async list(userId: string) {
return supabase
.from('education_entries')
.select(columns)
.eq('user_id', userId)
.order('created_at', { ascending: true })
.returns<EducationEntry[]>()
},
async get(id: string) {
return supabase
.from('education_entries')
.select(columns)
.eq('id', id)
.maybeSingle<EducationEntry>()
},
async create(userId: string, input: EducationInput) {
return supabase
.from('education_entries')
.insert({ user_id: userId, ...input })
.select(columns)
.maybeSingle<EducationEntry>()
},
async update(id: string, input: EducationInput) {
return supabase
.from('education_entries')
.update(input)
.eq('id', id)
.select(columns)
.maybeSingle<EducationEntry>()
},
async remove(id: string) {
return supabase.from('education_entries').delete().eq('id', id).select('id')
},
async setPrimary(id: string) {
return supabase.rpc('set_primary_education', { education_id: id })
},
}
Loading